Going to the Wikipedia definition, you’ll get a vague and unhelpful description, and to summarize: It tests a small piece of code.
In what language? What is small? And why does that matter?
I feel that many times in software, we’d rather concentrate on the mechanics, rather than on the goal. For example, we talk about mocking (how we do it) while we actually want isolation (which is what we need for our tested code).
First, let’s take a look at our goals for a good unit test:
- Tell you quickly when there’s a real problem
- Get you as quickly as possible from “Found a problem” to “Fixed a problem”
Let’s take a closer look, using FAIL.
Functionality: A unit test is a sensor, telling us if a former working functionality no longer works. While feedback is the requirement from every kind of test, the key thing is functionality, and in code terms – logic: if-thens, try-catches and workflows inside the code.
Accuracy: A unit test should fail for only two reasons: We broke something and should fix the code (A bug), or we broke something and should fix the test (A changed requirement). In both cases, we’re going to do valuable work. When is it not valuable? Example: If the test checked internal implementation, and we changed the implementation but not the functionality, this does not count as a real problem. The code still does what it was meant to, functionality didn’t change. But now we need to fix the test, which is waste. We don’t like waste.
Instant: A unit test runs quickly. A suite of hundreds and thousands unit tests runs in a few seconds or minutes. The success of applying a fine-grain sensor array relies on quickness in scale. This usually translates in the tested code to be short and isolated.
Locator: When there’s a problem, we need to fix it quickly. Part of it is testing a small amount of code. Then, there’s more we can do in the test to help us solve the problem. Yet, we need to think outside the context of writing the test, though. Someone else may break it, in a year or more, after we’ve moved companies twice. In other words, we’re leaving a paper trail to locate the specific problem for someone else. To do that we use accurate naming, readable test code, testing small portion of the code in a specific scenario, isolation from any undetermined or non-specific dependency, and lots of other tricks that will help our unfortunate developer achieve a quick fix.
Notice that none of these attributes are about the experience of writing a test. It’s about getting the most value out of it after it’s there.
The only value you get while writing a test, is when the code is not there yet. That’s right, in TDD. In that case, you get all of the above, plus insight about the design and safe incremental progress.
All other kinds of tests, which are also valuable, don’t have all these traits: Integration tests don’t locate the problematic code. UI tests are brittle. Full system tests are slow. Non-functional tests just give you feedback, and exploratory testing is not about functional correctness, but rather on business value (at least should be).
If your test passes the FAIL test, then it is a unit test.