Lessons from TDD - Logic in unit tests
TL;DR - Try not to have logic in unit tests. The whole point of a unit test is to make sure some logic works
TDD was something new to me about 8 years ago. I worked as a test automation engineer for a large corporation during my internship. My job was to automate integration tests. When I became a full time developer and starting writing production code, I learned about TDD. I realized that it’s one of the best tools that I had ever come across. Since then my mantra has been red-green-refactor. I have met newer engineers who ask the question - why test it if it works?
If you work in a large codebase, you’ll realize that refactoring an existing system is relatively crazier if it has a lack of test coverage. Especially when the code is business critical. Having a well tested system gives you way more confidence in refactoring since you know that if you mess something up, there will most likely be a test case that fails. Now, I’m not saying that if all tests pass, the system functions as expected. That’s not guaranteed unless all tests were written correctly. I’ve seen tests that are wrong which result in wrong code. With enough test coverage, at least I don’t have to take the car out for a spin every time I tighten a screw.
One of the biggest flaws I have seen in testing is the use of logic in tests. The purpose of a test is to make sure a certain piece of code works the way it is supposed to. The moment you add some logic in the test, the test starts to become complex. Here are two fun things I’ve learned -
Loops around tests
I have come across test suites where a bunch of test methods are wrapped in a loop. Think of tests for a generic DAO that works with multiple tables. If you want to make sure it works with all the tables, do not wrap all the tests in loop and execute them against the tables.
class FooDao(val table: String) {
def writeStuff(stuff: Stuff): Unit
def readStuff(): Seq[Stuff]
}
class FooTest extends FunSuite with Matchers {
Seq("tableA", "tableB", "tableC").forEach { table =>
val dao = new FooDao(table)
//test write
//test read
}
}
At least in Intellij if you run one of the tests independently, it’ll tell youthat the test passed. But try putting a failing assertion in it and you’ll notice that the test does not actually run. The correct way to write this test is to use shared behavior - https://www.scalatest.org/user_guide/sharing_tests.
Conditions in test code
It is harder to follow test code that has conditional logic. Conditionals imply that you need to have multiple tests/suites instead. If your test has conditions, is there test code that tests you test?
Take the case of beta features. If you have certain tests that need to work when a beta feature is turned on, and all other tests need to work in both cases, it’s always best to duplicate the tests and have one test that covers all the cases plus the ones specific for the beta feature, and another that does not have the tests for the beta feature but specifically turns it off for testing. That way when your beta feature code is cleaned up, you just have to delete the old test and not worry about whether or not you deleted too much.
There are many more little things you can do to avoid having logic in tests. These are the two most important ones that I have come across a lot.