Automated tests are one of the core principles for writing maintainable code. When we have tests, we can refactor code without worrying we might break existing code. They also, help us catch bugs that would otherwise end up in production. Good tests can be compared to checkpoints that guide the developers throughout the entire process and provide them with reliable feedback if the code is working properly. However, it is not easy to write good tests. The worst feeling is when you have tests that start failing and are hard to debug (often we end up commenting them out). This causes developers (and project managers) to lose faith in the usefulness of testing.
In this post, I will give you some essential tips on how you can upgrade your testing game and hopefully inspire you to write some tests of your own.
Tips covered in this post:
- Structure your tests
- Extract setup from tests
- Keep your tests simple
- Do not test implementation details
- Test naming
- Tests are repeatable
- Check that your tests are actually working
- Avoid magic numbers or strings
- Tests are code too
- Integrate tests in your CI/CD process
- Create documentation
- Testing hero
Structure your tests
Developers love writing code, still most of the time we end up mainly reading it. This is especially true when dealing with test code. When a test fails(and it oftentimes does) we don't want to waste a lot of time trying to figure out what the test was supposed to do. We should focus on making tests more readable by using the AAA pattern:
- Arrange -> setup your application state
- Act -> execute the piece of code you want to test
- Assert -> check that the new application state is what you expected after executing the code you were testing
It is great to visibly separate these parts by using comments or regions if your language supports them. If you are using Visual Studio Code
you can use the #region
to organize your code so that parts of the AAA pattern are visible:
Having a consistent structure in all of the tests, helps out developers look at test code more easily, and at a glance figure the important parts and debug them if necessary.
Extract setup from tests
Usually, the hardest thing to do when you are writing tests is mocking the state of your application so that you can run the code you want to test. In most situations when you analyze a particular test code, the biggest part will be the setup. That's why it is great to extract this setup in separate functions. This has certain benefits:
- It allows us to name our setup so that the state we wanted to achieve is clear
- It allows us to reuse our setup if we have multiple similar tests
- Our test code is now cleaner and we can clearly see the aim of the test
Be careful when reusing extracted test setups. By reusing them in multiple tests, they will probably be extremely complex (since each test needs a specific application state). You also might break some existing tests by adding new lines of code into your setup just to avoid repeating previous steps. In those cases, it is better to break the DRY (do not repeat yourself) principle, and have multiple simple test setups than one complex. You can always create testing utilities that can be used to create application state in setups if you have code that is often repeated.
Keep your tests simple
Avoid using loops or conditions inside your tests. Tests should have a cyclomatic complexity of 1. If you find yourself writing something like this:
test('Test', () => {
...
if (isDeleted) {
// assert deleted state
}
else {
// otherwise assert alive state
}
...
})
that is a sign that you should have used two tests. By keeping tests simple you will minimize the risk of including bugs into them and they will be much easier to read and debug when needed. Also, prefer to have multiple simple tests, then having a single test that asserts multiple things.
Do not test implementation details
Always write tests from the perspective of the user. This will make your tests more robust, and they can survive refactors of the code they test. If you find yourself needing to change your tests often then you probably tested implementation details.
If you write tests for code that exposes a user interface, structure your test to follow steps that your user might take. This way you can change the internal structure of your code, but if the interface and behavior stay the same you won't have to change your test. This will give you confidence that your changes didn't break something unexpected.
Test naming
You should be able to figure out the aim of the test by simply reading its name. A good test name should also be understandable to business users. A good naming structure includes the application state before we executed our code, actions that were taken, and the expected state:
test('Logged in user clicks on refresh -> new posts are visible')
Tests are repeatable
For the same input/state tests should always fail or pass. If your tests sometimes fail, this is a sign that you have some randomness inside your code and will lose confidence in your tests. The usual suspect that may cause such behavior is using the current date in the code you test. You can extract code that provides this information in a utility that can be injected into your code so that you can be sure that your application state is consistent between test runs.
Also, every test needs to clean up after itself. Each testing framework has some way of running a cleanup code after test runs (for example afterEach
in jest), so the state from one test doesn't leak into another. Every test should be a unit in itself. Avoid writing tests that depend on the order of execution. It is extremely hard to debug tests that pass in isolation but fail as part of a test suite.
Check that your tests are actually working
If you are using TDD in your process, you will have immediate validation of whether your tests are checking the code you are writing or not. But if you write tests after you have written your code it is easy to write a test that will always pass. In those cases, you should always make slight changes to pieces of code that you are testing (e.g. reverse a condition), just to make sure your tests are working.
Avoid magic numbers or strings
When asserting test results we often use magic
numbers or strings. This makes it complicated later on when trying to decipher why we used those values. It is better to assign them to variables so that we can explicitly name them, especially if it happens to be that this value is the result of our setup.
expect(result).toBe(1); // magic numbers
const expectedUserPosts = 1; // now it is clear what 1 means
expect(result).toBe(expectedUserPosts );
Tests are code too
I have seen often that developers when writing tests, forget all coding principles. Tests are expected to evolve just as your codebase evolves. We must use good coding practices when writing tests, otherwise refactoring them would be a nightmare. Extract code pieces in the same test suite into helper functions so if you have to change something, you will have to change it in a single place. Also, try to use descriptive variable names and add comments to help developers that are debugging the test.
Integrate tests in your CI/CD process
Tests should be run after each change that is pushed into your main production code. If you use git
, you should run tests after each pull request so that you are sure that the new code didn't break any old behavior. Tests should be fast so that you don't slow down the development process. If you have some tests that are slow (e.g. E2E tests), you can put them in a separate test suite, and run them once a day so you don't slow down your process but also have ample time to fix the potential issues before you publish your code to production. Also, make sure all your tests passed before pushing new code.
Create documentation
Consistency is key when working in larger teams. It is important to have some sort of dev documentation, where you outline all of the rules that the developers should follow while writing tests. This way it is easier to onboard new developers and teaches them best practices when dealing with tests. Also, try to include examples of different test cases that developers can use when writing a new test since we usually have few distinct test scenarios depending on the type of code we are testing.
Testing hero
Writing tests can often be very frustrating at the beginning. You won't be sure what to test or how to mock some application state so that you can test your code. It is good to have a person in your organization that you can lean on and that can guide you when writing your tests. They can educate their colleagues on the best testing practices, ensure that tests follow the same naming and structure, that we don't test implementation details. They can also be involved in pair programming sessions to give more inexperienced developers insight into the thought process when writing tests.
Conclusion
In this post, I highlighted some tips that I find useful when writing tests. I hope you find them useful and would like to hear in the comment section down below some of your own tips that you find useful when writing tests.