-
Notifications
You must be signed in to change notification settings - Fork 39
TDD
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.
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.
- 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.
- Cheap- finding a bug before releasing the code is cheap. Fixing a bug after releasing code is always expensive at best, at worst- it damages your reputation.
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 | Logo | First 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.
Let's say we have a project called Demo
. Create it. Next create a project called Demo.Tests
- tests for the Demo
project.
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.
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.
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
.
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 :)
In order to run tests, you will first need to open Tests Explorer window. Refer to the image below:
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!
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:
[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:
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);
}
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.
Unit tests is the most common type of tests- which ideally should be written for every function we implement.
However, as you can see there are quite some more tests.
Here is a quick summary of each test type:
Test | Description |
---|---|
Unit | Tests a single function |
Integration | Tests a single function in unison with dependent components |
Smoke | Shallow, happy path test just to see if a component (usually a process) is able to respond |
System | Testing a running system in the same way a client would call it |
Regression | A pack of tests either manually or automated, repeated against a part of system that changed |
Performance | Tests to verify if system meets speed (response time) and memory (how much memory does it consume, what load it can handle) requirements |
Security | Either a scan performed by a tool or a manual attack by an inside person to detect system vulnerabilities |
- 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.
- Shared state.
- Complicate your tests. In that case, you will need to test the tests.
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:
- Create a test. Create classes that don't exist and call non-existing methods on them. Know what you expect and verify that.
- The test will not compile- it's a failing test.
- Make test compile by creating (or generating) the needed classes and functions.
- Run the test. It will compile- but fail.
- Implement the code in the most simple way possible
- Run the test. It will pass.
- Refactor code.
- Run the test. It will pass.
- 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
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.
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.
Example code can be found here.
- 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?
Refer to Lesson 8 homework and unit test it.
Fundamentals of practical programming
Problem 1: International Recipe ConverterLesson 1: C# Keywords and User Input
Lesson 2: Control Flow, Array and string
Lesson 3: Files, error handling and debugging
Lesson 4: Frontend using WinForms
RESTful Web API and More Fundamentals
Problem 2: Your Online Shopping ListLesson 5: RESTful, objects and JSON
Lesson 6: Code versioning
Lesson 7: OOP
Lesson 8: Understanding WebApi & Dependency Injection
Lesson 9: TDD
Lesson 10: LINQ and Collections
Lesson 11: Entity Framework
Lesson 12: Databases and SQL