Repeating Yourself Is OK (especially in tests)
Elliot Nelson
Posted on March 22, 2021
When writing unit tests in languages like Ruby and JavaScript, a well-tested function often has more (maybe even 2-3x more) total lines of unit tests than it does code.
Because we have all this unit test code, when we notice lines that are repeated many times, it can be tempting to pull all those lines out and create helper functions or test wrappers. However, this desire to avoid repetition can easily lead us to the Wrong Abstraction.
A bad abstraction is a problem anywhere, but in unit tests it is especially bad, because that slick little helper function -- the one that over time has grown to take 5 arguments and contains several nested if statements -- doesn't get tested itself. When the code intended to test the code has its own logic (if
statements, switch/case
blocks, etc.), it's impossible to tell whether it's testing the code-under-test properly.
What's important is that "DRY" (Don't Repeat Yourself) is not an end goal in itself, it is only one tool to use in the pursuit of code that is readable and maintainable. If DRYing your code actually makes it harder to read and maintain, then it isn't serving its purpose.
For example:
// This example is in Jasmine; feel free to imagine it in
// Jest, Mocha, RSpec, Cucumber, etc.
describe('restartServer', function () {
it('resets keepalive', async function () {
server.stop.and.resolveWith();
server.start.and.resolveWith();
await instance.restartServer();
expect(server.stop).toHaveBeenCalled();
expect(server.start).toHaveBeenCalled();
expect(instance.keepalive).toEqual(0);
});
});
In my opinion, even if you have 20 more unit tests just like this one that test various different aspects of server restart behavior, it's usually not worth it to try and DRY up those stub lines.
To some of you this might seem to fly in the face of normal coding practice, but, let's look at some common situations:
A unit test unexpectedly fails. The developer goes to the test in question -- everything this test needs to run is on the screen in front them. No jumping around different code files or functions, it's all right there.
Someone adds a line of code to the function and they need a new unit test. Copy and paste an existing test, make some tweaks, and the task is done.
Someone makes a logic change to the function, causing the
stop()
call to be skipped in some cases. Half of the unit tests break, the rest continue working, and the developer fixes the unit tests that broke by fixing those expectations. (What doesn't happen? The typical Wrong Abstraction pattern: adding yet another boolean argument to the Big Helper Function and making yet another expectation conditional...)
My stance is that almost always, attempting to "DRY" unit test code comes with significant disadvantages to readability and maintainability -- almost in reverse proportion to the usual advantages it gives you in your code-under-test.
What do you think?
Cover image by Ben Allan.
Posted on March 22, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 23, 2024