Five Tips to Write Better Test Cases for Your Code
Automated testing of code is a fantastic way to make sure that our code works as expected. A well written test suite will make sure that your code does not only handle the in- and outputs that you expected to handle, but also that it handles any unexpected inputs gracefully. We can make sure that our code throws the correct errors when things go wrong, and we can make sure that any edge cases don’t throw off the balance in our code.
Additionally, we can test whether a user progressing through a sequence of steps correctly updates our underlying data, and whether the users are always presented the correct steps within such a sequence depending on previous choices.
I could list many more things that you could test with a well written test suite but let’s be honest, you can test virtually anything that your app does with the appropriate tests.
In this post, you will learn about five important tips to keep in mind when you’re writing your tests. These tips will help you whether you’re working on unit tests, integration tests, or UI tests because they are intended to help you improve and sharpen your testing mindset rather than focussing on the code that you write to actually perform your tests.
Tip 1: Let your tests guide you
When you’re given a specification to work on it’s tempting to fire up your code editor and get to work. Whether you were given a design, a JIRA ticket, or a flowchart, it makes sense to want to hit the ground running and start making progress on the task at hand.
Unfortunately, you’ll often find that no matter how much effort your manager, designer or client put into writing their specification, there’s some pieces missing. How do we deal with values that are out of bounds? What happens when a user doesn’t have an internet connection? What if the user’s device runs out of battery in the middle of completing this step in the onboarding flow?
We can often come up with some good questions by looking at the specification we were given and pondering what it is we’re looking at for a moment. However, we can do much better than just pondering. We could be writing tests!
In the early stages of working on a feature it’s a good idea to immediately start writing tests. When a user has the option to book a reservation for a restaurant, what are some things you could try and do? For example:
- What if the user would like to book a table for 60 people?
- What if the user would like to book a table on a Sunday afternoon at 4:30am?
- Can people book tables 6 months in the future? 12 months?
- Should the calendar allow a user to book a table 5 minutes from now?
We could transform these questions into tests quite easily. We might not know the expected outcome yet, but we know the test we would write. For example:
- Given that a user has selected a table for tomorrow at 6pm for 60 people
- When submitting the reservation form
- Then ????
We can start writing these kinds of behavior driven tests for all questions we have, and for every feature we see. Once you do this, you’ll find that it’s easy to change the number 60 to 4 and write a new test. Or the word tomorrow can be swapped for yesterday, next week, or next month and you’d have three more test cases.
With these kinds of tests more is better, every test case you can come up with now will make for a more robust experience. And you will have a much better sense of what it is you’re building.
Let your tests guide you towards writing more tests. Let your tests guide you towards asking the right questions. And lastly, let your tests guide you towards writing more robust code. Even if you don’t translate every behavioral test to a unit or UI test, having gone through the exercise of writing these tests down is tremendously useful.
Tip 2: Don’t focus on test coverage
Having 100% test coverage is a goal that many developers strive for, yet very few developers actually achieve this goal. Some developers strive for a more reasonable sounding 80% coverage because they feel like about 20% of their code is doing repetitive or really hard to test things that aren’t worth the hassle.
Depending on the language you’re coding in, it might be harder or easier to achieve a certain level of code coverage, so you should definitely take this tip with a small grain of salt because in some languages it’s quite common to focus on 100% test coverage and actually achieve this goal.
Regardless, testing your code for the full 100% does not guarantee that all your code actually works correctly. It just means that each line of code in your codebase was executed at least once.
If I want to know whether a function that transfers money from one bank account to another works well, I could have 80% coverage or more on that function quite easily. Given a user with €100.000,- in their bank account, I can try to transfer €1,- to another user and assert that the original user now has €99.999,- and the other has €1,-.
What I’m not testing is what happens when I try to transfer more than a user has in their bank account. Doing that should probably throw an error. I’m also not testing what happens if the receiving user’s bank account is locked. Or if the sending user’s bank account is locked.
In this example having a high test coverage simply meant that I was testing the happy path but none of the edge cases. Aiming for 100% coverage would of course fix this because I would have to go through every codepath possible, including the unhappy paths.
However, in a language like Swift it’s really hard to aim for 100% coverage since it’s not trivial to test for things like crashes.
Ultimately, test coverage is more of a vanity metric than anything. It’s much better to try and have a suite of tests that make sense than to have 100% coverage at all costs. Sometimes it’s fine to skip some of the mundane bits of your codebase and focus more on the business-critical parts of your codebase instead.
Tip 3: Ignore third party code
One mistake that I have seen many developers make is to try and test whether then can persist data in a Core Data or Firebase database as a unit test. While it’s good to know that when you make an instance of your model and you try to persist that model everything works as expected, it’s not a particularly useful test. With tests like these you’re not testing anything you control. You’re testing somebody else’s code and generally speaking we should trust that the other person tested their own code.
Even if the library you’re using isn’t tested, has bugs, or is otherwise flawed it doesn’t help that you have a unit test for it.
As a best practice we should always strive to write our tests independent from code we don’t own or control. This includes things like a networking stack or server that hosts our data.
When we’d talk to a server in a test then the test might fail due to the server being down. This failure is not one that provides us with any useful information. We cannot fix this test by changing our code which should be a good indicator that we should not be testing this component in the first place.
Mocks, stubs, interfaces, and fakes are all useful tools that allow you to swap out third party code for special implementations that you provide to your tests only so that they can run fully independent from third party code.
There’s absolutely something to be said for also having integration tests that do include third party code instead of mocked implementations. Doing this will not only make sure that your code works fine, it also makes sure that your code can communicate with other code that you’ll use in production.
Tip 4: Your tests can help document your project
When you have an extensive test suite, you’ll find that your test suite will tell you a lot about how the code in your codebase should behave. Especially when your tests are well written and organized, you’ll find that looking at your test suite provides a tremendous insight into everything that your code should and should not be doing.
Paying attention to your test suite really pays off when you start adding your behavioral tests (the given, when, then tests from tip 1) along with adding context about why certain assertions are the way they are (documenting business requirements). Maintaining a test suite that contains lots of documentation and information takes time, but it also saves a lot of time.
Whenever you’re getting ready to make changes to business logic in your codebase you can refer back to your tests, read up on why the codebase works the way it does today, and you can start making changes to the test suite before you start working on the new logic. While doing this you might notice that certain rules would be incompatible with the logic, or that the impact of a small change is much bigger than you would have expected.
By keeping your tests up to date, you allow your tests to continuously guide and educate you. Maintenance takes time, but the long term benefits should save you lots of time in the long run. This is especially true for large, complex code bases with lots of business logic and rules.
Tip 5: Your tests are part of your codebase
Lastly, your tests are part of your codebase. It might be tempting to treat your tests as a place where you can write quick and dirty code, have tons of repetition, and use short non-descriptive variable names.
If this is your mindset while writing tests you will have a really hard time with maintaining your tests, and you will most certainly have a very hard time using your tests as documentation.
You should always treat your test suite with the same amount of care and attention that you apply to your production code. Sure, you won’t be writing the same kinds of abstractions and maybe you care a little bit less about whether something is actually reusable or not, but you should always strive to have a high quality test suite.
When you’re reviewing somebody’s code and they included tests, you should always apply the same attention to detail, guidelines, and best practices that you would when you review the rest of the code.
Treating your tests as quick and dirty throwaway code will almost always lead to a test suite that nobody likes to work on, and your test suite itself will eventually become more of a burden than a tool that helps you maintain the high standard of code quality that you wanted to achieve when you started building your test suite.