Skip to content
AlmantasK edited this page May 23, 2021 · 7 revisions

Introduction

Professional programming is all about writing high quality code. Giving proof that the code you wrote works is an essential part of it. In today's lesson we will learn how to use unit tests to ensure expected behavior and quality.

What is a Unit Test?

Unit test is a test for a unit. In other words- it tests one thing. The smallest unit which we can test is a function. A unit test ideally tests a single function, in isolation from all other dependencies.

Why Automated Tests?

  • Fast- it takes seconds to verify if your code works. It removes the monkey job of clicking and checking if your code works.
  • Documentation- it talks about how the code should work under what conditions.
  • Fool proof- if you have tests in place, changes are less likely to break things- your tests will prevent from doing unintentional.
  • First line of defence against bugs- it's true that tests won't catch all bugs, but they will catch a lot of bugs before they even come into surface.
  • Localise mistakes- if you write unit tests- you will know not just that code fails, but which function does not give the expected results and why.
  • Helps you practice programming- writing code is definitely more challenging than manually clicking through a series of steps. Automated that process requires a good understanding of it. Most importantly, automating stuff will make you a better programmer.

The cost of tests- finding bugs is cheap

The 3 Options

As programmers, we will write an automated tests for our code. An automated test requires a framework to recognize, run and report them. In .NET, you have 3 testing frameworks to choose from. Below is a summary of each:

/> /> /> />
Framework LogoFirst release Remarks
xUnit 2008 Successor of NUnit (made by the same person. Most OOP. Author's recommendation.
NUnit 2000 Comes from JUnit. Very standard, similar to other languages. .NET started with this one. Most feature-full.
MSTest 2005 Microsoft wanted their own test framework. The slowest, least neat. Was reborn after open source community took over. Not recommended.

For a more detailed comparison, refer to Testing P1: 3 Frameworks and 3 Steps, slides 11-19.

For the rest of this boot camp, we will be using xUnit as the testing framework.

Setup

New Project

Let's say we have a project called Demo. Create it. Next create a project called Demo.Tests- tests for the Demo project.

New test project for xUnit

Linking Code Under Test to Tests

Code from 2 different projects is not accessible to each other, unless they are referenced. From the tests project, add a reference to the project under test.

Link tests and project under tests

What Will Be Tested?

We will be testing Point logic. From math- a distance between two points is expressed by a formula:

Let's implement it!

Create a Point class which is made of X and Y coordinates. Implement DistanceTo method to calculate distance between two points and ToString method to print the state of a point (coordinates).

    public class Point
    {
        public int X { get; }
        public int Y { get; }

        public Point(int x, int y)
        {
            X = x;
            Y = y;
        }

        public double DistanceTo(Point other)
        {
            if (other == null) throw new ArgumentNullException(nameof(other));

            return Math.Sqrt(
                Math.Pow(X-other.X, 2) + 
                Math.Pow(Y-other.Y, 2));
        }

        public override string ToString()
        {
            return $"X={X}, Y={Y}";
        }
    }

The only interesting bit here is the use of nameof. It allows us to convert the name of a anything into a string. We could have written "other" instead of nameof(other), however, if we did and in the future we decided to rename other, we would need to change the value of a string as well. However with nameof(other), we would be forced to update the input, rather than having to remember it, because it would not compile. Prefer to use nameof rather than hardcoding variable names.

Facts and Theories

In xUnit, every test is either a fact or theory.

Fact- a statement that is always true. In other words- parameterless tests.

Theory- a statement that varies based on input. In other words- tests with parameters.

Without further ado, let's write some tests!

Inside the tests project, create a new class called PointTests.

Parameterless Test

ToString() will always express the values of X and Y in a string. The way the values are printed will always be the same- it's a fact.

A test name is made of 3 parts:

  • What method is being tested?
  • What's the input/preconditions?
  • What do we expect?

For ToString the answers are:

  • We test ToString
  • No input - it's a fact
  • X and Y to be in the output

Every test is just 3 steps:

  • Arrange- prepare for test, setup what will be tested
  • Act- call a method under test
  • Assert- verify the expectations

For ToString the steps will:

  • Arrange- create a new point
  • Act- call ToString()
  • Expect "X=XOfPoint, Y=YOfPoint"

And so a test is born:

[Fact]
public void ToString_IncludesXandY()
{
    // Arrange
    var point = new Point(1, 2);

    // Act
    var description = point.ToString();

    // Assert
    const string expected = "X=1, Y=2";
    Assert.Equal(expected, description);
}

Please note, that while separating the parts of a test name we used an underscore (_). This is because test names tend to be way more descriptive than normal method names. When reading long names, it's scientifically proven that _ helps more than Pascal Case.

[Fact]- is an attribute called Fact. Methods, classes and properties can be decorated with attributes. About them in future lessons. For now, it's enough to know that [Fact] is an attribute that allows the test to be detected by a test runner and it will run the test without any parameters.

Writing tests might look intimidating, but it's only at the beginning. When you get into a habbit of writing tests, it will be easy and fun! Remember, it's just 3 steps :)

Running Tests

In order to run tests, you will first need to open Tests Explorer window. Refer to the image below:

Tests Explorer window with the first test passed

Color green and a checkmark indicates a passing test. Color red and an "X" indicates a failing test. You should be seeing green.

Exciting, isn't it? Let's write more tests!

Parameterised Test

This time we will test DistanceTo method. The output depends on the other point's coordinates. It's no longer a fact- it's a theory.

Our theory is that if we measure the distance between two points, it will be as expected. In cases like this, we cannot specify the expectation further, but it is intuitive enough that we're basing this on maths.

The test name:

  • We're testing DistanceTo
  • Our input is another existing point
  • We expect a distance between those 2 points to be returned

The test content:

  • Create 2 points
  • Call DistanceTo
  • Assert whether the distance is as expected

There are so many combinations that we could test for... How do you pick the input for test? It's all about the border cases and edge cases. Also, it's all about simplicity. Let's pick a few simple, yet covering-all scenarios:

Distance bwetween... is:

  • Points in origin- 0
  • The same points- 0
  • Both points at the same places in Y axis, X is different- |X2-X1|
  • Easy square root: sqrt(3*3+4*4=sqrt(25)=5
  • Include negative coordinate

Our input will be the 4 coordinates and the expected distance. In xUnit, we can pass primitive input through [InlineData] attribute.

Our test will look like this:

Primitive Parameters

[Theory]
[InlineData(0, 0, 0, 0, 0)]
[InlineData(1, 0, 1, 0, 0)]
[InlineData(1, 0, 0, 0, 1)]
[InlineData(3, 4, 0, 0, 5)]
[InlineData(0, 0, 3, 4, 5)]
[InlineData(5, -2, 8, 2, 5)]
public void DistanceTo_WhenTheOtherPointExists_Returns_DistanceBetween2Points(
    int x1, int y1, int x2, int y2, double expectedDistance)
{
    var point1 = new Point(x1, y1);
    var point2 = new Point(x2, y2);

    var distance = point1.DistanceTo(point2);

    const int precision = 2;
    Assert.Equal(expectedDistance, distance, precision);
}

Please note that we used a word When to specify the input part. If there are any preconditions (for example a file existing on a system) we will use a word Given.

[Theory] is just like a [Fact]- an attribute which helps the test runner detect actual test methods. The only difference between a [Fact] and a [Theory] is that a theory expects parameterised test method.

If you run the tests, you will see green. But please note that DistanceTo_WhenTheOtherPointExists_Returns_DistanceBetween2Points can be expanded and individual test cases (scenarios) inspected:

Parameterised tests for DistanceTo

Non-Primitive Parameters

In this scenario- it was okay passing primitive parameters. However, 5 parameters on a method makes it harder to read. More so, there will be scenarios where objects are more complex and evaluating them through primitives might not be possible. In those cases, you will need to use a different kind of input source.

There are 3 input types you can choose from: InlineData, MemberData and ClassData. ClassData is rare and thus we will not talk about it. Let's Focus on MemberData.

MemberData solves the problem of passing non-primitives as a test argument. All we need is to specify the name of a static property which returns IEnumerable<object[]>.

Our distance between 2 points expectations could be expressed like this:

public static IEnumerable<object[]> ExpectedDistancesBetweenPoints
{
    get
    {
        yield return new object[]
        {
            new Point(0, 0), 
            new Point(0, 0), 
            0
        };

        yield return new object[]
        {
            new Point(1, 0),
            new Point(0, 0),
            1
        };

        yield return new object[]
        {
            new Point(1, 0),
            new Point(1, 0),
            0
        };

        yield return new object[]
        {
            new Point(3, 4),
            new Point(0, 0),
            5
        };

        yield return new object[]
        {
            new Point(0, 0),
            new Point(3, 4),
            5
        };

        yield return new object[]
        {
            new Point(5, -2),
            new Point(8, 2),
            5
        };
    }
}

We introduced a new element here- yield. Yield loads each item in a collection in a lazy way. There are no 6 test scenarios here. When 1 of them is required- it will be created and loaded and then the next one and so on, so forth. We don't need to have all 6 scenarios created all at once and yield helps us to achieve just that. This is called lazy loading- or loading on demand.

And the new version of the test will look like this:

[Theory]
[MemberData(nameof(ExpectedDistancesBetweenPoints))]
public void DistanceTo_WhenTheOtherPointExists_Returns_DistanceBetween2Points(
    Point point1, Point point2, double expectedDistance)
{
    var distance = point1.DistanceTo(point2);

    const int precision = 2;
    Assert.Equal(expectedDistance, distance, precision);
}

Verifying Exception

Tests shouldn't be all about happy paths. Testing failure is as important as testing success. Handle errors, but only when you can do so in a meaningful way.

In the case of DistanceTo method- if another point is null, we should not continue.

Key answers:

  • Method under test- DistanceTo
  • Input- null point
  • Expectation- expection

Whenever DistanceTo takes null- it will fail- it's a fact.

Test:

[Fact]
public void Distance_WhenOtherPointIsNull_ThrowsArgumentNullException()
{
    var anyPoint = new Point(0, 0);
    Point nullPoint = null;

    Action distanceToNullPoint = () => anyPoint.DistanceTo(nullPoint);

    Assert.Throws<ArgumentNullException>(distanceToNullPoint);
}

Here, we introduced a new element in code- Action. Action allows to store methods as arguments. () => anyPoint.DistanceTo(nullPoint) stored the evaluation of a distance into distanceToNullPoint variable. Assert.Throws<ArgumentNullException> specified the expected exception and passing the Action as an argument tried executing the function. The function failed, the method caught the exception and verified it and all is well- test passed.

Others Kinds of Tests

Unit tests is the most common type of tests- which ideally should be written for every function we implement.

The types of tests. Unit tests- are the most common type

However, as you can see there are quite some more tests.

Here is a quick summary of each test type:

Best Practices

Do

  • One act per test case
  • Cleanup after tests
  • Make test fast
  • Make test reproducable
  • Test one (logical) thing

Logical thing refers to a unit under test. If the output consists of multiple properties that you need to verify- it's fine having an assert per each property.

Avoid

  • Shared state.

Don't

  • Complicate your tests. In that case, you will need to test the tests.

TDD

This lesson is titled "TDD". It stands for Test Driven Development. A common misconception is to think that if we are writing tests- it means we are doing TDD. That is not true!

TDD- as the name implies- makes tests drive the development process. It means that we start from a test and only then write actual code. How can we write a test for something that is yet to exist?

A test is a sandbox not just for testing if code works, but also coming up with a design. Here is how it works:

  1. Create a test. Create classes that don't exist and call non-existing methods on them. Know what you expect and verify that.
  2. The test will not compile- it's a failing test.
  3. Make test compile by creating (or generating) the needed classes and functions.
  4. Run the test. It will compile- but fail.
  5. Implement the code in the most simple way possible
  6. Run the test. It will pass.
  7. Refactor code.
  8. Run the test. It will pass.
  9. Repeat...

The flow above is Red-Green-Refactoring and is the essence of TDD.

You might think that this is a waste of time and makes no sense. I thought so too when I first heard about it! However, the only complexity here is just a matter of getting into a habbit of solving problems that way.

TDD is good, because:

  • It gives us more time to design the code the way we want (in a test)
  • Will enforce us to write a test (because it comes before code)
  • Is minimalistic- we will write just enough code to pass the test

TDD in Action

Our Point implementation was great! However, now we need to go one step further and calculate a distance of a certain route.

Let's apply TDD and implement a Route class.

A route is made of points. Our route will not change over time. It must include 2 or more points.

Requirement one- support for 2+ points:

[Fact]
public void NewRoute_StartsWithMultiplePoints()
{
    var routePoint1 = new Point(0, 0);
    var routePoint2 = new Point(0, 1);
    var routePoints = new []{routePoint1, routePoint2};

    var route = new Route(routePoints);

    Assert.Contains(routePoint1, route.Path);
    Assert.Contains(routePoint2, route.Path);
}

This code won't compile, because Route is a non-existing class. We will need to create it:

public class Route
{
    public Point[] Path { get; }

    public Route(Point[] path)
    {
        Path = path;
    }
}

The code compiles and the test passes. Well done!

Let's continue with requirement two- must be at least 2 points. If it is less than that- it should throw InvalidRouteException:

[Theory]
[MemberData(nameof(InvalidRoutes))]
public void NewRoute_WhenLessThan2PointsOrNull_ThrowsInvalidRouteException(Point[] routePoints)
{
    Action newRoute = () => new Route(routePoints);

    Assert.Throws<InvalidRouteException>(newRoute);
}
public static IEnumerable<object[]> InvalidRoutes
{
    get
    {
        yield return new object[] { null };
        yield return new object[] { new Point[0] };
        yield return new object[] { new [] { new Point(0, 0) }};
    }
}

The code won't compile- failing test. Let's add an exception class:

public class InvalidRouteException : Exception
{
    public InvalidRouteException() : base("Null points")
    {
    }

    public InvalidRouteException(int pointsCount) : base("A route must have at least 2 points, but was "+ pointsCount)
    {
    }
}

Code compiles, test fails. Let's update the constructor to validate the path:

public class Route
{
    public Point[] Path { get; }

    public Route(Point[] path)
    {
        if (path == null) throw new InvalidRouteException();
        if (path.Length < 2) throw new InvalidRouteException(2
        
        Path = path;
    }
}

Tests pass!

Lastly- the whole reason for implementing Route is calculating the total distance of it.

[Theory]
[MemberData(nameof(ExpectedPathDistances))]
public void CalculateDistance_ReturnsASumOfDistancesBetweenAdjacentPoints(Point[] routePoints, double expectedDistance)
{
    var route = new Route(routePoints);

    var distance = route.CalculateDistance();

    const int precision = 2;
    Assert.Equal(expectedDistance, distance, precision);
}
public static IEnumerable<object[]> ExpectedPathDistances
{
    get
    {
        yield return new object[]
        {
            new[]
            {
                new Point(0, 0),
                new Point(0, 1),
            },
            1
        };

        yield return new object[]
        {
            new[]
            {
                new Point(0, 0),
                new Point(0, 1),
                new Point(0, 0),
            },
            2
        };
    }
}

It's worth noting the simplicity of the test input. We did not add a whole bunch of scenarios for routes with 4, 10 or 20 points- it does not change the behavior at all! If route works on 2 and 3 points- it should work for all other points. 2 points might not involve a loop- therefore a scenario for 3 poitns was also included. Also note the simplicity of points themselves. The most simple scenario was taken- distance between points = 1- because we have already testing the distance between points calculation already.

If you run the test- it won't compile- failed test. Add a method CalculateDistance to Route class. Code compiles, but the test fails. Implement the method- see how this and all other tests now pass.

public class Route
{
    public Point[] Path { get; }

    public Route(Point[] path)
    {
        if (path == null) throw new InvalidRouteException();
        if (path.Length < 2) throw new InvalidRouteException(2
        
        Path = path;
    }

    public double CalculateDistance()
    {
        double totalDistance = 0;

        for (int i = 0; i < Path.Length-1; i++)
        {
            var currentPoint = Path[i];
            var nextPoint = Path[i + 1];
            var distance = currentPoint.DistanceTo(nextPoint);
            totalDistance += distance;
        }

        return totalDistance;
    }
}

Like this, you can add as many requirements as you want, with a near 100% trust that the code works as before.

Incremental development is the true power of TDD.

Summary

Tests are great, because they are the best proof that our code works not just on our machine. Tests are the first net against bugs. Tests are useful for more reasons than just catching bugs, this includes: documentation, improvement for your skill as a programmer, fast development, ... Don't trust tests 100%, manually test from time to time as well. Have the majority of tests in unit tests format. Try to write tests before writing code, this will ensure that you don't forget writing tests for the code you wrote. This will also ensure that you don't write more code than you need.

Did You Understand the Topic?

  • What is a unit test?
  • What is TDD?
  • Why do we need tests?
  • Should we trust tests 100%
  • What are the 3 steps in tests?
  • What are the 3 testing frameworks?
  • What is xUnit?
  • What is the difference between a fact and a theory?
  • What other kinds of tests are there?
  • What is the difference between just writing tests and practicing TDD?
  • Why do we need TDD?
  • Why is nameof useful?
  • Why is yield useful in tests?

Homework

Refer to Lesson 8 homework and unit test it.