loading...

A Primer on Testing & TDD

hagnerd profile image Matt Hagner ・6 min read

There are two types of people in the world: those who love testing in React, and those who have never tried @testing-library/react.

I joke, but this article is a light introduction into what test-driven
development is, why I find it useful with front end development, an overview of what should be tested, and what the differences are between different kinds of tests.

There are a lot of opinions that I express in this article that pertain to my personal style of development. You may not agree with some or all of my opinions and that totally is fine.

This is the first article in a series on testing in React. In the next post I will talk about @testing-library/react for unit testing, so if you'd like to be notified of future posts make sure to follow.


What is test-driven development?

Test-driven development took rise during the early 2000s. The basic premise was that we should write our tests before we write our implementations to avoid the false-positive affirmation that can happen when you write tests after you write your implementation.

The main ethos of test-driven development can be summed up in three words: red, green, refactor.

You write a test that you know will fail because you haven't implemented the feature yet. You write code to make that test pass. And now you can refactor the portion of code that is tested with confidence.

You repeat the cycle by writing more tests to cover other aspects of the feature, tests against regressions, and tests against edge cases you discover.

Test-driven development can be extremely powerful, however, the goal isn't to have 100% code coverage and you shouldn't feel the need to write a test for every little thing. This can be a slippery slope and at some point writing more tests is not going to increase your confidence in the code base, or make you more productive.


Why do I write tests?

I write tests because I've found that when employing test-driven development it helps me write more ergonomic components. I avoid poor design choices the first time around because I write the usage before I write the implementation.

Having tests written for a component or feature helps me refactor with confidence. If the test was passing before I made a change and the behavior is expected to be the same, then any change I make should not make the test fail. If it does I've either 1) broken the contract and will need to update all usage of the component throughout the application, or 2) tested implementation details and I should delete the tests if not needed, or update them to not test implementation details.

I've also found that when practicing test-driven development I'm a lot more focused. I can write down some expectations, set a timer for 25 minutes, and then get to work. I focus on one thing at a time. Write a test. Watch the test fail. Start implementing the feature. Watch the test pass. Refactor if needed. And then move on to the next thing. I tend to get a lot more done a lot faster than if I just start writing code without a clear direction in mind.


What should we test?

A better first question might be who should we write tests for? The answer is pretty simple. Our users. As developers we have two users of our code. The actual end-user who is interacting with our website or application, and our future selves or other developers who will use our code to implement other features or make changes to our code.

For example let's say we need to make a Button in React. There could be a developer who uses that Button in a different part of the application, and there could be a user of the app who interacts with that Button.

The parts that those two users need to interact with should be the things that we test. What are their expectations? What are our intentions?

The user probably expects to be able to interact with it. Depending on the context of the button on the page they could expect it to submit a form, lead them to a new page (a link that looks like a button), to increment a counter, to save some data, etc.

The developer might expect to be able to pass their own click handler into it and have it fire reliably. They might expect to be able to change the button text, override, append, or modify styles, they might expect to have some mechanism for disabling the button.

Some of these expectations can be tested in a generic way at the unit level, and some will make more sense as an integration test where the component is actually being used in a specific context.

We can even use static testing via linters or tools like Storybook and the a11y addon to test our code for best practices. Like making sure that we are using a button for interactive elements or passing the DOM element the applicable aria properties like role=button, aria-pressed and making it focusable if we are using something like a div.

A11y is shorthand for accessibility. A, 11 letters in the middle, y.


Unit vs Integration vs End to End

Guillermo Rauch once tweeted "Write tests. Not too many. Mostly integration". I think this is a pretty good tweet to model your testing practices after.

So what are some of the different types of tests?

Unit Tests

Unit tests are tests centered around a unit of code. It could be a singular function, or a component. When you first start testing you will typically write a lot of unit tests. Soon you will realize though that they don't really give you confidence in your application, instead in an isolated piece of code. You end up having to mock a lot of things, and any time you have mocks in your tests your overall confidence in these tests are lowered.

Note on mocking. Sometimes it's necessary to mock some functionality or 3rd party library. Take data fetching for example. You don't want to be making requests to APIs in your tests because this would make your tests slow. You also don't know when or how many times your tests will be run so making potentially dozens or hundreds of requests every time your tests run 😬 That's a big yikes from me.

Integration Tests

Integration tests focus on larger chunks of code. In React it could be a page, or a larger component like a form that contains a bunch of smaller components. Integration tests are the bread and butter of testing. This is where you are testing the actual usage of your components, instead of testing the potential usage.

End to End Tests

End to end tests are typically harder to set up, and more costly to run. You should still consider having end to end test in your code base. End to end tests simulate user interaction through the entire application/website. Usually you will test certain flows through the application like signing a user up, making a new post, editing the post, or deleting a post the user is authorized to delete.

Property-Based Tests

Property-based testing hasn't really made too many waves in JavaScript but is popular in languages like Clojure and Elixir. We won't do any property-based testing in this series but the idea is that you test a provable property (think of mathematical properties) against your piece of code, you use some sort of input generation, and it can catch edge cases where that property breaks.

Static Tests aka Static Analysis

While not necessarily tests in the traditional sense, static analysis is the combination of tools like Eslint, and type checkers (if you're using a statically typed language like TypeScript), among other things, that allow you to check your code for correctness in some way. When static analysis is used correctly it helps you catch potential bugs early, or notify that you are doing something you shouldn't, like putting an onClick handler on a div instead of just using a button. When used incorrectly, like using Airbnb's Eslint rules, static analysis will cause you headaches and make you significantly less productive, unless of course you work at Airbnb and need to adhere to their code style guide.


Conclusion

We've talked about what test-driven development is, why I like to practice test driven development, how to identify what we should be testing, and what the difference between different types of tests are.

If you have any questions please post them in the comments.

Discussion

markdown guide
 

TDD-style unit tests are great for languages that do not provide contracts. Alas, the majority of popular languages don't provide this facility.

For languages that do provide contracts (Eiffel, D, Spec#, Sing#, M#, and soon C++20), unit tests are still useful to fill in the gaps that contracts don't cover, but there will not be nearly as many unit tests.

The biggest value for TDD-style unit tests is how it impacts the design. It is a forcing function for YAGNI, KISS, DRY code (and WET unit tests), Clean Code, TDA ("Hollywood principle"), and a good portion of SOLID.

The residual secondary value of enabling refactoring with confidence is very liberating. And the tertiary value of having a unit test suite as a byproduct to ensure basic correctness from regressions is good too.

I'd value those things as x1000 value for better design, x35 value for refactoring with confidence, and x1 value for ensuring basic correctness.

The other point I like to make about the difference between unit tests and other kinds of tests is that unit tests should be written by the developer as a development aid, run super quickly (seconds) for the entire unit test suite, which are run in debug mode. (It is somewhat unfortunate that "Test-Driven Development" has the word "test" in it, because some developers seem to think that tests should be written by quality engineers and not the software engineers, or that "Test-Driven Development" is about the testing -- which it is not, it is all about the design.)

In contrast with other kinds of tests like integration tests, performance tests, usability tests, acceptance tests, system tests. Which should be written by quality engineers, run against the optimized release builds, and may take hours or even days to run the full test suite.