[EN] TDD: The 3 things you can test


When you're just starting with TDD, feeling lost is totally normal.
You don't really know what you're supposed to test, you're kinda poking around blindly… and if you're learning a test framework at the same time because you've never written a single test in your life… well, that's the perfect recipe for a few hours of confusion.
But there are a few simple ideas that can really help.
They won’t magically fix everything, but they’ll definitely help you get unstuck when you don’t know where to begin.
Because if you take two seconds to really think about it, aside from edge cases like perf or security testing, there are only three ways to check that a piece of code is doing what you expect.
Not ten. Not fifty. Just three.
Once you get that, everything starts to make a lot more sense.
The only question you need to ask
Start by asking yourself this one simple question:
Is the result I want to verify direct, indirect, or part of a collaboration?
I can already see your face:
“Uhhh what? I have no idea what that means. Nevermind…”
→ Don’t worry. Let’s walk through it together with examples.
1. Direct output
Let’s start with the simplest one, and the one you’ll try to use most often.
It also happens to feel the most natural.
Imagine the code you're writing is a "machine".
You don't really care what’s going on inside.
That machine has one purpose.
In our case: “create a potion from a herb”.
→ So the machine takes an herb
as input...
→ ...and gives you a potion
as output.
If we translate this into a TDD-style test, you might write something like:
it('should create a green potion from green herb', () => {
// Arrange
const machine = new Machine();
const herb = 'green-herb';
// Act
const potion = machine.createPotion(herb);
// Assert
expect(potion).toBe('green-potion');
});
Pretty straightforward, right?
You feed a herb
into the machine, and the return value is a green potion
.
→ That’s a direct result.
Direct results are the easiest to test: you pass something in, you get something out, you check the output.
It’s the same with a simple calculation. Let’s say you're writing a sum()
function:
it('should make the sum of two numbers', () => {
// Arrange
const a = 1;
const b = 2;
// Act
const result = sum(a, b);
// Assert
expect(result).toBe(3);
});
In short: if your code returns a value, it’s direct, and that’s the easiest kind of thing to test.
2. Indirect output
Sometimes things are a bit trickier.
The result exists, but it’s visible somewhere else, and your test needs to account for that.
Let’s tweak our potion machine example just a bit.
Our machine is connected to a lamp that turns green when the potion is ready.
In this case, the lamp
is external to the machine.
The machine can somehow access it and update its state.
How? Who cares? What matters is that the machine doesn’t return the lamp.
→ So this is an indirect result.
Here’s how that might look in a test:
it('should change the color of the lamp to green', () => {
// Arrange
const lamp = new Lamp();
const machine = new Machine(lamp);
const herb = 'green';
// Act
machine.createPotion(herb);
// Assert
expect(lamp.color).toBe('green');
});
You can clearly see from the test that the machine
has access to the lamp
, and after creating a potion
, the lamp turns green.
It’s still readable and testable, but it’s a little more work than the direct case.
And that’s your second way of verifying results. One more to go.
3. Collaboration
In some tests, what you want to verify isn’t a result or a state.
It’s a behavior, something that happened.
You want to check whether the right thing was done by a collaborator.
Let’s go back to our potion machine:
The lamp should blink 3 times to signal that a potion is ready.
Now blinking the lamp could take time. It could drain power. Maybe those lightbulbs are expensive.
→ In tests, we want speed, low cost, and predictability.
So the better move is to use a test double: a fake object that helps us verify what happened.
We want to verify that our collaborator lamp
had its blink
method called exactly 3 times.
it('should make the lamp blink 3 times', () => {
// Arrange
const lamp = new Lamp();
spyOn(lamp, 'blink').mockImplementation();
const machine = new Machine(lamp);
const herb = 'green';
// Act
machine.createPotion(herb);
// Assert
expect(lamp.blink).toBeCalledTimes(3);
});
✋ Heads up! There are lots of ways to create test doubles.
Here I’m just sticking with what we’ve already discussed.
I usedspyOn
withmockImplementation
(from Jest or Vitest) because you’ve probably seen that before in docs or tutorials.
But it’s not the only way!
In a future post, I’ll show how to create your own test doubles without any library, and break down the differences between dummies, stubs, spies, fakes, and mocks.
So here, you’re not checking a return value or a state.
You’re checking that another element was used correctly.
→ That’s what we call verifying a collaboration.
TLDR:
Whenever you’re about to write a test, just ask:
What does the code produce? → Direct
What does it change somewhere else? → Indirect
What does it ask another object to do? → Collaboration
Want to go deeper?
Obviously, the names I use here are simplified, but this structure will help a ton when you're getting into TDD.
If you want to read more, here are the “official” names for these techniques:
Direct → State Verification
Indirect → Observable Side Effect, Indirect Output
Collaboration → Interaction Testing, Behavior Verification
Some books if you want to dig in deeper (Amazon affiliate links):
xUnit Patterns (Gerard Meszaros): https://amzn.to/42pw3hZ - Old one but great for testing patterns
TDD by Example (Kent Beck): https://amzn.to/3YvZu0G
In an other article, we will discuss about direct and indirect inputs, stay tuned!
Subscribe to my newsletter
Read articles from Evyweb directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
