I've been writing software for a long time. I've also been writing tests for quite a long time.
And yet, every now and again, someone will ask: “Is this a unit test or a feature test?” And I'll stop, look at the test, look at the code, look back at the person who asked, and think: does it actually matter?
Well, yes. But also... not nearly as much as we sometimes make it sound.
We've accumulated a whole vocabulary for testing software: unit tests, feature tests, integration tests, acceptance tests, end-to-end tests, smoke tests, regression tests, contract tests, functional tests, and probably several others I've forgotten while writing this.
The easiest way I've found to understand the different types of tests is to stop thinking about the names for a moment. Instead, ask: “What am I trying to prove?”
Those are all tests. They're just testing different things.
A unit test tests a small, isolated piece of code: usually a class, method or function.
class PriceCalculator
{
public function addVat(float $price): float
{
return $price * 1.2;
}
}
public function test_it_adds_vat()
{
$calculator = new PriceCalculator();
$this->assertEquals(120, $calculator->addVat(100));
}
There's no database, HTTP request, browser, authentication, queue or external API. Just input → code → output.
The big advantage is speed. You can have thousands of these tests and run them very quickly. Because they're isolated, when one fails you generally have a pretty good idea where the problem is.
Now suppose our application has POST /orders. A user submits an order; the application validates it, stores it,
calculates the total and perhaps sends an email. A feature test might exercise that whole process.
$this->post('/orders', [
'product_id' => 123,
'quantity' => 2,
])->assertSuccessful();
We might then check that an order exists in the database. This isn't testing one individual method: it's testing a feature of the application.
A unit test might prove, “the order total is calculated correctly.” A feature test might prove, “a customer can place an order.” Both are useful; they're looking at the problem from different distances.
Integration tests take us another step outwards: they test whether two or more components work together. The application and database; the application and a payment API; or the application, database, queue and external API.
A unit test might mock the database. An integration test deliberately doesn't, because we want to know whether our application and the database actually work together. Here's the uncomfortable truth: mocks can lie.
You can have a beautifully written unit test saying everything is fine while the real database is sitting there thinking: “No it isn't.”
An end-to-end test behaves like a user: open the application, log in, click something, fill in a form, submit it and check the result. Perhaps using Playwright, Cypress or Selenium.
Open website
↓
Log in
↓
Create order
↓
Pay
↓
Receive confirmation
They're valuable, but usually slower, more complicated, more fragile and harder to diagnose. If one says the order process failed, you may have to investigate the browser, JavaScript, HTTP, authentication, database, queue and payment service. So we don't necessarily want everything to be an end-to-end test.
A smoke test is basically asking: “Is the thing alive?” The name comes from switching something on and checking that it doesn't immediately start smoking.
You don't need to test every feature. You just want to know that the application hasn't fallen over completely.
Does GET /health return 200?
Can we load the homepage, connect to the database, or log a user in? If those basic things don't work, there's probably not much point running another 4,000 tests.
Regression is less about what a test looks like and more about why we're running it. A regression test makes sure something that used to work still works.
Fix a bug where customers can't download invoices, and add a test. Six months later someone changes the invoice system and the test fails. Excellent. It has caught a regression. A unit, feature or end-to-end test can all be regression tests.
These become useful when APIs and multiple systems are talking to each other. An external application may expect:
{
"id": 123,
"name": "The Grand Hotel",
"rooms": 42
}
If someone changes rooms to room_count, our API tests might all pass and our application might work perfectly.
But the other application has just exploded. A contract test checks the agreement: “Are we still providing what the other system expects?”
Acceptance testing asks whether the software meets the requirement. It's less “does this method return 42?” and more “can the customer actually do what the business asked us to build?”
Given a booking exists
And arrival is more than 24 hours away
When the customer cancels the booking
Then the booking should be cancelled
And the customer should receive confirmation
We're talking about behaviour rather than implementation. The test doesn't particularly care whether the application uses Laravel, Symfony, PHP, Python, a database, or a particularly enthusiastic collection of goats. It cares that the requirement is satisfied.
Functional testing is broadly whether the software performs a required function correctly: can I create a customer, generate an invoice or reset my password? Depending on who you ask, these might instead be feature, acceptance or integration tests. The terminology overlaps.
Don't imagine these as completely separate boxes. Imagine them as different dimensions.
How much code? What are we proving? Why run it?
UNIT CODE REGRESSION
↓ ↓ SMOKE
INTEGRATION FUNCTION CONTRACT
↓ ↓
FEATURE REQUIREMENT
↓ ↓
END-TO-END USER JOURNEY
A test can be a feature test which is also a regression test; an integration test which checks an API contract; or an end-to-end test which verifies an acceptance criterion.
Not “should this be a unit test or an integration test?” but “What risk am I trying to remove?”
I think developers sometimes get too hung up on the terminology. I've seen discussions about whether a particular test is technically a unit test or an integration test, while the actual test is doing a perfectly good job of telling us that the software works.
That's the bit that matters. Tests are there to give us confidence. They should tell us something useful, catch problems, be reasonably fast and maintainable, and give us some idea what has gone wrong when one fails.
Some names describe what you're testing. Some describe how much of the system you're testing. Some describe why you're testing it. And some are used differently by different teams. The important thing isn't memorising the vocabulary; it's understanding the question each test is trying to answer.
| Test | Basically asking... |
|---|---|
| Unit | Does this small piece of code work? |
| Integration | Do these components work together? |
| Feature | Does this feature actually work? |
| Acceptance | Does it meet the requirement? |
| End-to-End | Can the user complete the whole journey? |
| Smoke | Is the application basically alive? |
| Regression | Does something that used to work still work? |
| Contract | Do two systems still agree on how to communicate? |
| Functional | Does the required function work? |
And if you're still confused about the difference between a feature test and an integration test, don't worry. You're not alone. I've been doing this for years. I'm still occasionally tempted to call them all: “Tests that stop production breaking.” Which, to be fair, is probably the most useful definition of all.
Don't start with the label.
Start with the risk you are trying to remove.
Mocks can lie.
The real database occasionally has a very different opinion.