Cover Photo by Yan Krukov from Pexels.
As part of my learning journey with The Odin Project, I was exposed to test driven development (TDD). I wanted to write about my experience with it because while doing that lesson, I wanted nothing more than to skip it and move on to the next thing. In the end, I stuck with it and I do think it made for a fruitful learning experience. I am hoping some other learners might find this relatable and help to push them to stick with learning TDD. In the end, I would say I came out with an appreciation for TDD and how it helps you to write better code.
I had a really hard time with the whole concept of TDD for a number of reasons. Tests-first and how to write tests was new, and using mocks and doubles was strange to me. And most of all, writing tests wasn't the same as writing my program!
Learning something new
The first big barrier was that taking a TDD approach was completely different from what I was doing before. I'd write some code, then test it using IRB. It worked, so why bother with TDD? I had a lot of thoughts about how TDD was cumbersome and pointless during the first day with TDD. Why would I write tests first when I could just write the code, and then test it? It felt like a duplication of effort that led to the same end result.
A large part of this feeling of tedium stemmed from actually having to learn something completely new. Tackling RSpec was like learning Ruby all over again and seem like a huge mountain to climb. Why should I climb it when I can just build the thing I really want to build anyway? At the time, I didn't recognize this barrier for what it really was. What I was frustrated with, really, was not knowing how to do the things I wanted to do. It wasn't that testing was difficult to pick up, but RSpec had very specific ways of doing things that I hadn't quite grasped yet. Things like subject
context
and all the different matchers for expect
were as foreign to me as Array#reduce
was when I first picked up Ruby. This feeling of frustration was so visceral, it really almost caused me to give up. I'm glad I didn't. Once I spent some time playing around with testing in RSpec, all the ways of doing things started to be added to my mental map. What was once a huge pain became easier, slowly but surely. Suddenly, writing a test in RSpec wasn't this huge pain in the ass where I'd have to look at the docs constantly.
Duplication of effort?
The second pain point was entangled with how familiar I was with the RSpec language, but I think it's worth calling out separately. I initially felt that it was a huge duplication of effort to write a test and then the code to pass that test. It would definitely have been faster to write my Ruby code first then figure out how to test it afterwards. At this stage, I knew how to write Ruby, but RSpec? At best, I'd have to spend some time figuring it out. That felt frustrating to me, and so it felt like a barrier to finishing the project which led to my feeling of want to can the whole thing and move on. Because RSpec was new to me, skipping the tests to write Ruby right away was very appealing. However, as I got more familiar with RSpec the barrier to figuring out tests lowered to the point where the whole idea of TDD started to make a lot more sense. After all, once you know how to use RSpec, writing a test is very simple.
A test defines, in a very concrete way, what I am expecting my code to do. That's valuable when you're creating something from scratch. Putting into words (literally) what outcomes you're expecting articulates your small problem at hand. Then, you write another test. Add or refactor your code to pass both. In this way, you're building your code in small chunks of functionality.
The other big benefit to learning how to use tests is something that you don't really broach working independently but is critical in a collaborative context: tests help to ensure a code base behaves in a particular way. When you're the only person working on a project, it isn't really consequential. You are the only one who works on the code and you know exactly what you want it to do. However, in a collaborative environment where you might be working on code someone else wrote? That is a whole new ball game. In this situation. if there's a test then you know what your code changes have to maintain in terms of behaviour.
What the heck are mocks?
The final barrier to testing involved the concept of mocks and doubles. I won't go into detail here their differences, as there are plenty of experts and resources on the topic. Generally, they are stand-ins for objects that you might not have written yet or resources that are external that you don't have direct control over like an API or external database. You don't want to send a bunch of requests to a live API when you're testing something in a development environment.
Using mocks was difficult for me to wrap my head around at first. It was a bit of an echo of the second pain point I had: I didn't really know how to use these things, and why wouldn't I just write unit tests and then the actual class instead of using a mock? Again, I struggled here to really identify my core problem: I needed more familiarity with how to use this tool. One insight that helped me really understand this tool was the idea that you should test the unit, not the integration.
This concept was a little baffling to me and it exposed a weakness in my familiarity with object oriented design in the first place. As a small note, this following section is based heavily on the things I learned from Sandi Metz through her talks at various Rails Confs as well as her two books on object oriented design. Using mocks was confusing to me because I realized I was testing the integration of my two objects, rather than testing the unit.
An example here might be helpful, so let me set the stage. The project I was working on was a command line Connect4 game, and I was trying to test that my Game object would display the rules to the console. At this point in the project, Display
was not yet written so I was using a double as a stand-in.
Here was my first go at the test:
describe Connect4 do
describe '#show_rules' do
subject(:c4_rules) { described_class.new(display: display) }
let(:display) { double('Display') }
it 'should print "Rules" to stdout' do
allow(display).to receive(:print_rules).and_return(nil)
expect(c4_rules.show_rules).to output('Rules').to_stdout
end
end
end
I set up the class I am testing and an instance of it as well as a stand-in Display
. After the set up is complete, we allow Display
to receive the message print_rules
and return nil
. After all that, I assert (or expect) that the game object (c4_rules
) should send the message show_rules
, and then output something to the console.
This test is bad. It is bad because it is testing integration between Connect4
and Display
. If you think about it, why should the game object know what the Display outputs to the console? In this case, I am testing for the output string 'Rules'
but what if my rules change? This test would be broken because of a change in Display
but we were testing Connect4
here!
Testing in this way makes mocks hard to use as any change anywhere else in your code has the potential to break tests related to another unit. It really does seem like you'd be better off writing Ruby rather than figuring out how to re-write your tests so they pass.
How can we change this test so that it is actually a unit test, rather than an integration test? After all, in TDD, the point is to make sure each class (or unit) behaves as we expect it to. Changes in another unit shouldn't break tests for the unit we're looking at.
Well, let's give it a go.
describe Connect4 do
describe '#show_rules' do
subject(:c4_rules) { described_class.new(display: display) }
let(:display) { double('Display') }
it 'should send Display the print_rules message' do
allow(display).to receive(:print_rules)
expect(display).to receive(:print_rules).once
c4_rules.show_rules
end
end
end
What changed? Well, a lot. Let's walk through it. All of the set up is mostly the same. We set up our test unit, then set up a stand-in Display
and allow it to receive the print_rules
message. This time, we don't care about what it returns. Then, we changed our test from expecting a certain output to expecting a certain message to be sent.
This shifts the test away from testing integration to testing the unit we're interested in testing. It also means that all the Display
double is doing is verifying that it received a message from Connect4
like we expect.
Thinking back, Connect4
shouldn't care what Display
does with the print_rules
message in the first place. Whatever is the result of that message, that is the concern of Display
and its own unit tests. All we need to test here is that Connect4
sends such a message. Now, regardless of what Display#print_rules
outputs, it won't break our test. The behaviour we expect of Connect4
remains the same: it sends a message to the Display to do something.
The key is to understand that each object in your program is a unit. These objects talk to each other in order to get things done. What's important in unit testing is to really focus on the interface of your objects. What do we expect our objects to do when it receives a message? Should it send a message to another unit for information or to do something? Should it send information back to the sender in its own response?
When you focus on testing the messages and responses of your objects rather than the end outcome of a message received or sent, the value of doubles becomes clear. In fact, thinking about the messages you want to send or receive, you either have to use mocks for objects you haven't written yet, or write them. In this way, a double is a nice way to figure out the messages that other object should send and receive, before you even start writing it.
Let's get testing!
Even though this whole process of learning the TDD method has been hard, it has totally been worth the pain and struggle. I see the value in unit tests and have even undertaken other projects in this way. Give yourself a chance to become familiar with your testing framework. Once you know how to write the tests you want to write, it will all become so much easier.
Beyond familiarity with your testing framework, make sure you're testing your unit as much as possible rather than integration with other units. It will help make testing much less painful, as changes far away from your current unit will be much less likely to break your tests.
I hope my experience going through learning TDD encourages you to also stick with it. It was frustrating, and I wanted to skip the lesson many, many times. But in the end, learning how to do TDD not only helped me to get a stronger handle on some design fundamentals, but also helped me to write better code.
One final thing, Sandi Metz has a fantastic talk on testing that really helped me to understand how to test better. This one video was hugely insightful and helped to tie everything I knew about testing and object oriented design together. If you're starting to learn TDD and hating it, give it a chance!
Top comments (1)
Absolutely