Get the most out of your tests
This post provides tips that can help you write great tests and get the best results from your test
Recently we had some discussion in our team around how we can get more out of our tests where I got an opportunity to share experience from my previous workplaces. I realized that I could share this with a wider audience.
The list below describes some of the practices we used to follow in my last team for writing useful tests.
Note: I have used the term “Tests” here loosely. The tests include Unit Tests, Functional Tests and Integration tests unless otherwise stated.
Have a meaningful name; test name should be able to explain Given-When-Then (GWT)
There are different ways we can arrange our tests to describe GWT. Here is one example of how we can achieve this:
- The name of the test class can denote “Given”. For example, the CreateCustomerCommandHandlerTests name describes the tests in the class are for CreateCustomerCommandHandler.
- The name of the test can provide When and Then of the information. For example, When_EmailIdIsInvalid_CustomerShouldNotBeCreated
- If When is obvious, we may choose to ignore it. For example, FirstName_ShouldNotBeEmpty
The end goal is that just by looking at the name of the test, a developer should be able to understand the purpose of the test.
Code coverage is a necessary but not a sufficient condition
It is common to have build checks to fail the build if we do not meet a certain code coverage threshold. However, just the code coverage alone mustn’t drive our tests. The purpose of the tests is much more than just testing all the paths in our code. The tests should cover our technical and functional requirements.
Tests are not step-child. Given tests, the first-class treatment
Do not treat your tests differently from your code. Give them the same love. A code path is usually validated by running against multiple tests. We generally end-up having more “test” code than “real” code in our solution. Hence, it is essential to maintain the same quality standard for tests, if not more.
An average code with a great test suite is better than a great code with an average test suite. That is because we can always refactor our code. And when we refactor, tests validate that we have not broken anything.
Keep unit tests clear and concise. Do not combine multiple tests into one.
Ideally, a unit test only asserts a “unit” or a single path. Combining multiple tests makes it less meaningful. For example, let us say we need to write tests for the class CreateCustomerValidator.
It is better to have multiple tests with clearly defined Assert like FirstNameLength_ShouldBeLessThanOrEqualTo50 rather than one single test WhenCustomerDataIsInvalid_ShouldThrowValidationError. The latter issue is that it does not give any information about how the data is invalid.
Tests are documentation
A good code is self-explanatory, and great tests are the documentation for our code. Taking the above example, FirstNameLength_ShouldBeLessThanOrEqualTo50 tells us that we have a business rule to restrict the first name to less than or equal to 50. If the business rule changes in the future, we know precisely which test should break and what we need to fix.
If we change code logic and a test does not fail, we are not testing right
Changing business logic in our code should be reflected by a failed test. If all the tests still pass even after changing a considerable part of the code, it shows we have a gap in our tests.
A bug in production? Write a test
If we find a bug in production or during the manual test, start with a test to reproduce that bug in your local development environment. The test obviously would fail first, and after fixing the bug, it would pass. In future, if we refractor that portion of the code, we have a test that helps to validate that we do not reintroduce the same bug again. It also ensures that the overall quality of the code increases as the code matures.
Use builder pattern to setup “Arrange” of tests
Builder patterns is a great way to set up the “Arrange” of our tests. Steve Smith has written an excellent post on how builder pattern can help keep our tests neat.
Try to use rich libraries such as Shouldly or Fluent Assertion
Shouldly and Fluence Assertion are assertion frameworks that focus on giving great error messages when the assertion fails while being simple to use. They provide an extensive set of extension methods that allow us to specify the expected outcome of the TDD and BDD-style tests. These libraries are not specific to a test engine such as XUnit or NUnit, so if different projects use different test engines, we still have one consistent way of Assert.
Try to use Libraries such as Autofixture, Bogus to generate test/ fake data
The libraries such as Autofixture and Bogus make it easier for developers to do Test-Driven Development by automating non-relevant Test Fixture Setup, allowing developers to focus on the essentials of each test case. The idea is also not to reinvent the wheel by creating our own “Random-Fixture” library.
So many times, I have seen code which “mocks” dependencies outside the class not being asserted. Not asserting mocks takes the value out of the unit tests. A test may be green even when the code is broken. All the major mock libraries such as NSubstitute, Moq, Rhino. Mocks provide an excellent way of asserting the mock result, such as is the method called with the right parameters, how many times the method is called, and so on.
If you can’t mock, use real dependencies. But still have a test.
When we write unit tests often, we restrict our tests within a boundary such as a single class. However, it is often not trivial to limit the test within the class for all the right reasons. In such cases, it acceptable to have tests that go beyond the boundary. Be pragmatic rather than restricting yourself to the traditional definition of tests.
For repositories, have functional tests if it makes sense.
Our repositories do much heavy lifting. They talk to an external data source and act as an interface between our business logic and database.
Writing unit tests for repositories is hard. Repositories cannot be mocked easily, and the in-memory database does emulate the same behaviour as a real “database”. In such a scenario, it is better to have functional tests rather than fighting unit tests.
Try to have an integration test for every Acceptance Criteria of your user story
As a developer, the onus is on us to validate if requirements in the user story are clearly defined and if we have met all the acceptance criteria specified in a user story.
Integration tests are a way to prove that. Every commit is a release candidate in a CI/CD world, and keeping code quality high is very important. If a QA can find a glaring bug in our story or then it reflects inadequate dev testing.
I do not claim the above list to be the best or recommended practice, instead consider them as a suggestion and do what works best for your team. I hope the post helps you to think deeper about how you can improve your test suite.
Hi... I’m Ankit Vijay. I hold around 14 years of experience in application development & consulting. I’m a Dotnet Foundation member. I have worked in various roles ranging from Individual Contributor, DevOps, Solution Architect, Consultant, and Dev Lead depending on the nature of the project. I am passionate about technology and write about the topics I love. If you like my blogs, you can follow me on Twitter @ https://twitter.com/vijayankit or GitHub @ https://github.com/ankitvijay