I often see unit testing completely overlooked until after code is written, and I have been hugely guilty of this in the past. Testing can sometimes be seen as taking longer, doesn’t add perceived value, or it’s too complicated. I’m going to try and change those perceptions, to help you or others around you see why tests are just as important as the code itself!
Why should I test?
There are many reasons why you should test your code, and all of them will help you! Unit testing your code is not to satisfy some code coverage statistic, it is a tool to do the following:
1. it proves your code works as intended 🎉
It is easy to write a block of code making a slight logic error no matter how good you think your code looks, perhaps an accidental inversion of logic (==
instead of !=
), forgetting a null
check, or just being under a time pressure. It happens to everyone, so testing your code will save self-embarrassment, and catch issues before your code makes it anywhere near production.
Well tested code will prove each path through your code, and ensure you get the expected output for a given input.
2. it stops unintended behaviour changes 🔥
In as little as a weeks time you will have probably moved onto something else, and the code you’ve just written will be out of your mind. Without tests in place, you or someone else could make a change which breaks the original intention of the code without realising. If you’re lucky maybe a tester or QA check will spot the behaviour change, but more than likely it will slip into production code and become a bug.
Well tested code will ensure the behaviour and original intention of the code remains in place, until you choose to change the tests and therefore intentionally change the behaviour.
3. it documents your code 📖
After a certain amount of time your memory will fade and you’ll forget the original reason for various parts of your code. One day you or a colleague may want to use or tweak the code, but not quite understanding the original reasoning.
Well written tests will act as a document of why the code does what it does, or why it provides a certain output.
4. it saves you time ⌚
No really… so often we think that writing tests adds a lot of time and if we just skip them we can deliver things faster. Stop for a minute and consider all the things that you do whilst writing new feature X, perhaps you build some UI, write some code to fetch some data, render the data in the UI, build the app, start tapping buttons and manually testing it does what you needed. Great you’ve built your new feature, what’s next? Perhaps you’ve covered all the scenarios in your manual test, but what usually happens is that you test the happy path where not a lot goes wrong, and you forgot to test with WiFi switched off, or with a slow connection, etc. Then next week when you build feature Y, will you go through all that manual testing again for feature X, of course not as there won’t be much time for coding soon!
So well written tests will save you time, sanity, and as the next point will demonstrate will free your mind to create better code.
5. it frees your mind 🧠
When building a simple application by yourself you can sometimes maintain the whole picture of what the app does, and how everything touches and affects each other. But once someone else is working on the same codebase, or the codebase becomes too large, it’s harder and harder to maintain the whole picture.
Well written tests can be run at anytime to prove that your code behaves as intended and hasn’t been accidentally broken by a change somewhere else in the code, freeing your mind to concentrate on just the unit of code your are working on, safe in the knowledge you aren’t breaking anything else.
6. it makes you a better programmer 🌟
Seriously, if you want to be a better programmer, get better results, less bugs, more “it just works” moments, then start writing tests that give you even more confidence in your code, and once you treat tests as first class citizens among your codebase, you immediately level-up as a programmer!
7. it is fun! 😀
You probably became a programmer because you enjoy creating apps or programs with all the challenges that brings, and tests are just programs, so if you like programming, you should like writing tests! They’re written in the same language, come with great tools, and can be challenging to write.
There is also a huge sense of satisfaction when you write a test that catches a problem you hadn’t spotted or intended.
What should I test?
Quite simply, you should test everything where possible. There’s no good reason to think that some parts of your code are ineligible for testing, and where there is a will there is a way.
Types of output to test
There are 2 types of output that you need to prove in your tests:
1. asserting the return value of a function
When your function returns a value then you MUST assert that it is the expected value.
class Calculator() {
fun multiply(a: Int, b: Int) = a * b
}
class CalculatorTests {
@Test fun `multiply should return 4 when a = 2, b = 2`() {
val result = Calculator().multiply(2, 2)
assertEquals(4, result)
}
@Test fun `multiply should return 32 when a = 8, b = 4`() {
val result = Calculator().multiply(8, 4)
assertEquals(32, result)
}
}
These tests assert that the result is correct given the different arguments, ensuring the calculation is working as expected.
2. verifying calls to external dependency functions
When your function calls another function on a dependency then you SHOULD verify that it’s been called with the expected arguments.
class Calculator(
private val piProvider: PiProvider
) {
fun piTo10Places() = piProvider.generate(decimalPlaces = 10)
}
class CalculatorTests {
private lateinit var mockPiProvider: PiProvider
@Before fun setUp() {
// stub the generate function to return the correct value
every { mockPiProvider.generate(decimalPlaces = 10) } returns 3.1415926535
}
@Test fun `piTo10Places should generate pi to 10 places`() {
val result = Calculator(mockPiProvider).piTo10Places()
// assert result is the expected value
assertEquals(3.1415926535, result)
// verify our mock was called with the right argument of 10
verify { mockPiProvider.generate(decimalPlaces = eq(10)) }
}
}
This test verifies the call to the dependency has been called with the correct arguments and ensures the behaviour is expected.
or a combination of both
In reality your functions will probably be a little more complex (but not too much, remember KISS and SRP principles) than those examples and there may be a little more to assert and verify. Often you’ll want to use a combination of assertions and verifications, but try to keep your test structured like this.
@Test fun `example test`() {
// setup stubs and mocks where needed
// call the function under test
// assert the results and verify the calls
}
How do I test successfully?
The key to testing successfully is to have a testing mindset, by this I mean you should have testing on your mind whenever thinking about, planning, or writing code. The more you write tests, the more this will happen naturally, so don’t worry if it’s hard to start with.
Imagine (or actually write down) all the outcomes
When you’re starting out creating a function or class, it helps to imagine or actually create some blank test scenarios for each outcome you want to achieve.
Item Repository
Let’s say we need to create a repository to get some imaginary items, we know we need to get lists of items to display a list view, individual items by their ID for a detailed view of the item, and we’d like to cache them in to speed up fetching them next time.
So let’s write some quick test cases down of the various scenarios or outcomes that could happen. Now this doesn’t have to cover absolutely everything, as we might want to add some extra coverage once we get into the implementation later.
class ItemRepositoryTests {
@Test fun `findAll should return items from API and store in cache when not cached`() {}
@Test fun `findAll should return items from cache when already cached`() {}
@Test fun `findAll should return empty list when not available in cache or API`() {}
@Test fun `findById should return item from API and store in cache when not cached`() {}
@Test fun `findById should return item from cache when already cached`() {}
@Test fun `findById should return null when not available in cache or API`() {}
}
As you can see, the tests are doing nothing right now, but we have detailed what we expect to happen in the different scenarios, so now we can start implementing a class to achieve those scenarios.
class ItemRepository(
private val api: ItemApi,
private val cache: ItemCache,
) {
fun findAll(): List<Item> =
cache.all() ?: api.all()?.also { cache.putAll(it) } ?: emptyList()
fun findById(id: String): Item? =
cache.findById(id) ?: api.findById(id)?.also { cache.put(it) }
}
With some creative use of the ?:
elvis operator and the also
scope function we can make it a one liner, but it’s definitely in need of some actual tests to prove this works correctly.
class ItemRepositoryTests {
@Mock private lateinit var api: ItemApi
@Mock private lateinit var cache: ItemCache
private lateinit var repo: ItemRepository
@Before fun setUp() {
repo = ItemRepository(api, cache)
}
@Test fun `findAll should return items from API and store in cache when not cached`() {
val itemList = listOf(item1, item2)
every { api.all() } returns itemList
every { cache.all() } returns null
val result = repo.findAll()
assertSame(itemList, result)
verifySequence {
cache.all()
api.all()
cache.putAll(itemList)
}
}
@Test fun `findAll should return items from cache when already cached`() {
val itemList = listOf(item1, item2)
val cachedItemList = listOf(cachedItem1, cachedItem2)
every { api.all() } returns itemList
every { cache.all() } returns cachedItemList
val result = repo.findAll()
assertSame(cachedItemList, result)
verify(exactly = 0) { api.all() } // ensure API is not called
verify(exactly = 1) { cache.all() }
}
@Test fun `findAll should return empty list when not available in cache or API`() {
every { api.all() } returns null
every { cache.all() } returns null
val result = repo.findAll()
assertEquals(0, result.size)
verifySequence {
cache.all()
api.all()
}
}
// I'll let you complete the remaining 3 tests 😄
@Test fun `findById should return item from API and store in cache when not cached`() {}
@Test fun `findById should return item from cache when already cached`() {}
@Test fun `findById should return null when not available in cache or API`() {}
}
I’ve left the last 3 tests as an exercise if you’d like to complete them. But there you have it, a fully tested class covered against accidental changes in behaviour, and we’re now completely sure this does what it’s been written to do.
How do I automate tests?
In any good development workflow you should be running your tests often, in your IDE/command line during development, perhaps running just a subset of tests related to the area you are currently working on, and you should be running all tests before committing code to your Git repository. But the best workflow will have a CI server running your entire test suite, ensuring all tests are passing before you merge your code into your master branch.
Continuous Integration (CI)
There are various options for continuous integration, from self-hosted options, to fully hosted and managed services that are triggered from a push to your Git repo. Here’s a few options to get you started:
- Bitrise (hosted, free-tier)
- GitHub Actions (hosted, free-tier)
- GitLab CI (hosted, free-tier)
- CircleCI (hosted, free-tier)
- Jenkins (self-hosted)
Frequently Asked Questions / Common Statements
My code is too hard to test
When your code is hard to test, perhaps with too many dependencies leading to lots of hard work mocking and stubbing, it’s a sign that you need to simplify and breakdown the code further, into more decoupled and manageable pieces.
Often when something is hard to test it is simply trying to do too much. Try to remember the KISS principle (keep it small and simple) and SRP (single responsibility principle), as they will keep you on track creating simple maintainable and testable code.
Post coming soon, on how to decouple your code…
Are you talking about TDD?
TDD or Test Driven Development is where you write the entire test cases and ensure they’re failing first, before writing the simplest possible code to make the tests pass. It’s a great practice but is often too bigger step initially for those who haven’t yet found the magic about testing. This post is intended to be a step towards TDD, but isn’t specifically mandating you follow exact TDD principles.
At the time of writing this post, I’m now heavily using TDD principles, but often I’m still using this more hybrid approach.
Final thoughts
I really hope this post has inspired you in some way to make testing a first class citizen in your development workflow. Having a philosophy around testing really will make you a better programmer and help you create awesome software!
If you have any questions, reach out using one of my accounts below.