|
|
|
| |
Hey Khalil,
The other day we talked about how to get started testing and refactoring legacy code.
Regardless of where you are in your testing journey - if you start a new feature or need to clean up existing ones, one thing is for sure:
Edge cases make testing really hard.
What do I mean by edge cases?
Basically, it’s easy to get the simple create-like success cases for our tests crafted out, but if we’re testing valuable stuff like entire features (be it E2e, integration, or unit test style), it’s the failure scenarios that get you thinking the most.
For example, imagine we were testing a successful createClassRoom scenario a School Domain.
It’s a simple create-like scenario that might look like this. |
| | | |
| |
| |
This test looks good, right?
Well, what happens when we run it twice?
If there’s unique constraint on the class name (and there is), it will fail.
So that means we’ve got an idempotency problem right away, and we haven’t even gotten to the more complicated tests yet.
Idempotency
What is idempotency?
We say that ”an API call or operation (vertical slice) is idempotent if it has the same result no matter how many times it's applied.”
Hmm, that’s clearly not the case here then.
Why does this matter with respect to our tests?
It’s REALLY important that we can run our tests over and over and get the same result. That’s the only way we’ll really know if they work or not! Inconsistent tests are not only extremely annoying for the false positives, but they can hold up an entire deployment pipeline.
So what do we do?
Well, it’s clear that the problem has to do with the unique constraint on the name field for the Class model in the database. We’re not changing that.
prisma/schema.prisma |
| | | |
| |
| |
| |
We've got options
But first, I want to zoom out to show you how I see all of this from a Systems Thinking point of view because that’s where we’re going in our journey towards Mastery.
Direct Inputs & Outputs
Alright, so most of us know about the simple TDD examples, like fizzBuzz and palindrome, right?
Well, those basic challenges are great to learn the fundamentals because they’re just so straightforward, but once we start to get into testing entire feature and systems (both on the frontend AND the backend), things go out the window a little bit.
Systems Thinking-wise, the simplest example is a function with a direct input and a direct output. |
| | | |
| |
| |
| |
But it’s a little bit different.
How?
Because we’re not JUST dealing with direct inputs and outputs.
We’ve also got to consider the indirect inputs & outputs as well.
Indirect inputs & outputs
The reality is that most meaningful slices of functionality — be it a backend API call or your controller layer on the frontend — aren’t single isolated systems like your fizzbuzz or palindrome functions.
The reality is that meaningful systems and slices of functionality almost always talk to other systems, which often means crossing the network into infrastructure-land.
On the backend through, at a bare minimum, this is usually going to mean fetching from and saving to a database.
So here’s the idea:
- An indirect input is a connected system that which affects the behaviour of the current system under test, based on its state.
- An indirect output is a state change that occurs against a connected system during the execution of a vertical slice of functionality.
Here’s an example: creating a user relies on a database to both: get the user (indirect input) as well as save the user (indirect output) before returning some data to the caller. |
| | | |
| |
| |
| |
Both are significant for literally everything we do later on with testing, but let’s focus on the indirect inputs right now specifically.
Indirect inputs = fixtures = the state of the world
Said differently, indirect inputs are the preconditions for your test.
Said differently, indirect inputs are the initial state of the world at this time.
Said differently, indirect inputs are your test states.
Said differently, indirect inputs are your test fixtures.
And because of this, your indirect inputs indirectly influence your test outcomes (ie: the direct output) based on their states. This is to be expected.
For example:
Feature: Creating a new class
- Success Scenario
- Indirect Inputs (preconditions/initial test state)
- Failure scenario
- Indirect Inputs (preconditions/initial test state)
Why look at it this way?
“Khalil, I get it. The database is the initial test state. Why do I have to think about systems and inputs and outputs?”
I know, this is fairly zoomed out.
But when you think about tests this way, you can’t unsee it. You’ll see it everywhere else you look, if you learn how to decouple the systems from each other properly.
It’s a really helpful model for the even more complex test states and edge cases we tend to encounter later on.
Idempotency techniques
So back to the issue of handling edge cases.
What I’ve found is that there are two general techniques we end up using: 1) randomness, and 2) resetting & constructing test states.
Idempotency technique #1: Randomness
It’s real simple.
Use random data.
I like to either write my own little text utilities, but I know that some folks prefer to use fakerjs .
Here’s an example. |
| | | |
| |
| |
| |
You shouldn’t have to worry about test collisions. Probably not going to happen.
Idempotency technique #2: Resetting & constructing test states
The second technique is called resetting and constructing.
I know those are two different words but they’re close enough in the behaviour that I just think of it all as one thing we have to do.
With this technique, the reset part means we want to reset the indirect inputs somehow for a clean test state.
We typically do this with a database reset script to reset afterEach , beforeEach , beforeAll , and/or afterAll tests. |
| | | |
| |
| |
| |
What about the reconstruction part?
Resetting everything is only one part, and it makes sense for the simplest slices of functionality, like we've seen — creating something new from scratch as necessary for a createUser or a createClassRoom success case…
But as we move forward, you’re going to see that it’s not enough to just tear down the state of the world to start from scratch.
What about something like makeTrade in a Vinyl Trading application where the initial state of the world is for feature like this would be much more complex?
Or what if our API routes were authenticated? How would you design your tests so that you’re always making authenticated requests? What about when you’re not authenticated?
Yeah, this is real world stuff.
For this, we’ve got to look at builders and fixtures. One of my favourite topics.
I’ll share more later this week.
—
As always,
To Mastery
Khalil
PS: We're wrapping up student feedback in The Best Practice-First phase before moving onto Pattern-First. I’m offering an additional 15% off of The Software Essentialist for the next 15 students who join before August 19th. Use the code TESTING-MASTERY to get in before the price goes up again. |
| | | |
| |
|