If you had to change one of your critical services right now, how confident are you that the change wouldn’t silently break something else in your codebase ?
And harder still — if the only way to be sure was to manually test the entire system by hand, what would you do ?
This is exactly where automated tests earn their keep.
Automated tests aren’t just a tool for catching bugs. In any serious project, they’re a core part of keeping the system safe to change. Tests are what let you refactor code, swap out dependencies, optimize a database, or even redesign a service’s architecture — without manually re-verifying the entire system from scratch every single time.
But here’s the catch: not all tests are equal.
There are many kinds of tests — Unit, Integration, Feature, Contract, Fuzz, End-to-End, and more — and each one solves a different problem. A test that’s extremely valuable in one project might just be maintenance overhead in another.
What Is Automated Testing, and Why Does It Matter in Backend Development ?
In simple terms, an automated test is a program that runs part of your software, compares the actual result to the expected one, and fails the test if there’s a mismatch.
For example, imagine a service that calculates the final price of an order:
subtotal = 1,000,000
discount = 10%
shipping = 50,000
Enter fullscreen mode Exit fullscreen mode
If we expect the final price to equal 950,000, we can turn that expectation into a test. From then on, every time the pricing logic changes, the test automatically verifies whether the expected behavior still holds.
Automated tests are extremely useful for catching regressions, documenting system behavior, and building confidence when changing code. They can also be plugged directly into CI/CD pipelines.
The goal isn’t to test every single line of code — it’s to cover the system’s most important risks at a reasonable cost.
Types of Tests in Backend Development
To really understand test types, you first need to define their scope — which specific part of the system a given test touches. That gives us:
- Unit Test
- Integration Test
- Feature / Functional Test
- Contract Test
- End-to-End Test
- Characterization Test
Alongside these, there are techniques focused more on how inputs are generated or how strong your test suite is:
- Property-Based Test
- Fuzz Testing
- Mutation Testing
And finally, tests that check the system’s performance and quality:
- Performance / Load / Stress Test
- Smoke Test
- Regression Test
These categories aren’t fully independent of each other. A Feature Test can also be an Integration Test at the same time, and fuzzing can be applied to a single Unit Test.
So what matters more than a test’s name is understanding exactly what risk it covers.
What Is a Unit Test ?
Unit testing is the smallest and fastest level of testing. In a unit test, you typically isolate a specific unit of behavior and verify that, given a specific input, it produces the expected result.
That “unit” can be:
- A function
- A method
- A class
- Or a small piece of domain logic
For example, take:
calculateDiscount(price, customerType)
Enter fullscreen mode Exit fullscreen mode
We could write tests like:
Customer = Regular
Price = 1,000,000
Expected Discount = 0
Enter fullscreen mode Exit fullscreen mode
and:
Customer = Premium
Price = 1,000,000
Expected Discount = 100,000
Enter fullscreen mode Exit fullscreen mode
In a unit test, external dependencies like the database, file system, or network are never brought in directly — instead they’re replaced with test doubles such as mocks, stubs, or fakes (we’ll cover each of these later in the article).
Microsoft’s definition of a unit test treats it as a test of a specific component or method, and explicitly places infrastructure pieces — database, file system, network — outside the normal scope of a unit test.
Advantages of Unit Tests
- Very fast
- Low execution cost
- CI-friendly
- Pinpoints the exact location of a bug
- Great for refactoring
- Lets you have a large number of tests
The Downside of Unit Tests
Every component might work correctly in isolation, but still break when combined.
For example:
OrderService
↓
Repository
↓
PostgreSQL
Enter fullscreen mode Exit fullscreen mode
OrderService‘s logic might be completely correct, but the query it sends could still be wrong. A unit test alone won’t catch that.
That’s where Integration Testing comes in.
What Is an Integration Test ?
Integration testing checks whether two or more parts of your application — or one part and an external dependency — can actually work together. Common examples in backend systems include:
- Application + PostgreSQL
- Application + Redis
- Application + Message Broker
- Application + File System
- Application + External API
- Repository + Database
Say your repository does something like:
CreateOrder(order)
Enter fullscreen mode Exit fullscreen mode
In a unit test, you’d probably mock the database.
But an integration test can:
- Spin up a real PostgreSQL instance
- Connect the application to it
- Save an order
- Read it back
- Verify the result
At this point, you’re no longer just testing your code’s logic — you’re verifying how components actually interact. Integration tests generally have a larger scope than unit tests and can pull in real infrastructure.
Advantages of Integration Tests
- Tests more realistic behavior
- Catches database-related issues
- Catches serialization issues
- Verifies configuration
- Gives more confidence than unit tests alone
Disadvantages of Integration Tests
- Slower
- More complex setup
- (Probably) requires real infrastructure
- Harder to debug
- Failures can have many possible causes
So it doesn’t make sense to write an integration test for every single piece of business logic.
What Is a Feature Test (or Functional Test) ?
A Feature Test checks a more complete capability of the system, from the perspective of its expected behavior. In a backend, this test can start from the API itself. For example, a feature around placing an order:
POST /orders
Enter fullscreen mode Exit fullscreen mode
Input:
{
"productId": 123,
"quantity": 2
}
Enter fullscreen mode Exit fullscreen mode
And we expect the response:
201 Created
{
"orderId": 456,
"status": "created"
}
Enter fullscreen mode Exit fullscreen mode
At this point, it no longer matters how many classes exist inside OrderService.
We’re testing the feature’s behavior: “Can a user create a valid order ?”
That’s exactly why a Feature Test differs from a Unit Test.
A Unit Test asks: does this class or function work correctly ?
A Feature Test asks: does this specific feature of the system behave as expected ?
That’s why Feature Tests are so valuable in API development.
How Is a Feature Test Different from an Integration Test ?
These two concepts can overlap. For instance, a single API test might flow through:
HTTP Request
↓
Controller
↓
Service
↓
Repository
↓
Database
Enter fullscreen mode Exit fullscreen mode
In that case, you’ve tested both the feature and the integration between its components.
So these aren’t necessarily two fully separate layers. A practical way to decide is to ask:
What risk does this test actually cover ?
If the goal is to verify that a feature works correctly from the API’s point of view, call it a Feature/Functional Test. If the goal is to verify the repository’s connection to PostgreSQL, call it an Integration Test.
What Is a Contract Test ?
Imagine the Order service sends a request to the Payment service:
POST /payments
Enter fullscreen mode Exit fullscreen mode
and expects the response to look like:
{
"paymentId": "123",
"status": "paid"
}
Enter fullscreen mode Exit fullscreen mode
Now imagine the Payment team changes that response without telling you:
{
"id": "123",
"state": "completed"
}
Enter fullscreen mode Exit fullscreen mode
Your unit tests might still be green. But the integration between the two services is broken.
That’s where Contract Testing becomes valuable.
A Contract Test verifies that both sides of an integration agree on a specific contract. For HTTP communication, that contract can define the expected request and response shapes — and the same idea applies to message-based integrations too.
One well-known approach is Consumer-Driven Contract Testing. In this model, the consumer defines its expected needs and interactions as a contract, and the provider verifies that contract.
The Benefit of Contract Testing
A Contract Test can reduce a chunk of integration risk between services without running the entire system.
This is especially useful when:
- Different teams work on different services
- Services are deployed independently
- APIs change frequently
- You have a microservices architecture
It’s important to note that Contract Testing doesn’t fully replace other test types. Instead, it’s designed for one specific problem:
Do the two services still agree on the expected interface ?
What Is an End-to-End (E2E) Test ?
An End-to-End Test, or E2E, checks a scenario’s entire path from start to finish.
For example, in an online store:
Login
↓
Add Product
↓
Create Order
↓
Payment
↓
Order Confirmation
Enter fullscreen mode Exit fullscreen mode
E2E tests run this entire path. In backend development, even without a UI, we can run this kind of scenario purely through the API:
POST /login
POST /cart
POST /orders
POST /payments
GET /orders/{id}
Enter fullscreen mode Exit fullscreen mode
And finally verify:
Order Status = Paid
Enter fullscreen mode Exit fullscreen mode
The Benefit of E2E Tests
They most closely resemble the system’s real-world behavior. That’s why, for critical business scenarios, they can create a very high level of confidence.
The Main Challenge with E2E Tests
They’re expensive.
E2E tests are usually:
- Slower
- Harder to debug when they fail, with more complex failure modes
- Dependent on a broader environment
- More demanding to maintain
That’s why it doesn’t make sense to test every system state end-to-end.
What Is a Characterization Test ?
Here we get to one of the most important techniques for legacy projects. Imagine you inherit a project that’s been in production for 10 years, but the project looks like this:
Test Coverage = Low
Documentation = Incomplete
Code = Complex
Enter fullscreen mode Exit fullscreen mode
The project manager says: “Just make one small change here.”
The problem ? You don’t actually know what the existing code does.
Something that looks like a bug to you might actually be behavior the system has relied on for years — behavior clients now depend on.
This is where Characterization Tests come in.
A Characterization Test isn’t meant to say:
“This behavior is correct.”
It says:
“This is the system’s current behavior.”
This approach matters a lot when working with legacy code, and alongside techniques like introducing a seam, it makes it possible to change old systems gradually and safely.
A seam is a point in the code where you can change or substitute a dependency in order to test or alter behavior. For example, if a service depends directly on a database, inserting an interface between them creates a seam — one that lets you swap the real database for a mock or fake during testing.
An Example of a Characterization Test
Say you have an old function:
calculateShipping(order)
Enter fullscreen mode Exit fullscreen mode
and there’s no existing test for it. You don’t know what result this input produces:
Order = 1,000,000
City = Tehran
Weight = 3kg
Customer = VIP
Enter fullscreen mode Exit fullscreen mode
Instead of guessing what the result “should” be, you run the function and observe the actual output. Say the output is 120,000. Now you turn that exact behavior into a test:
calculateShipping(order)
Expected = 120,000
Enter fullscreen mode Exit fullscreen mode
From this point on, if a refactor changes the result to 150,000, the test fails. In other words: before you fully understand the code, you’ve already captured its existing behavior.
When Is a Characterization Test Valuable ?
Specifically when:
- The project is legacy
- Test coverage is low
- Documentation is incomplete
- A major refactor is coming up
- An old system is about to be migrated
Important note: a Characterization Test doesn’t necessarily claim the current behavior is correct. You might later discover that the captured behavior is actually a bug. At that point, you can follow this path:
Current Behavior
↓
Characterization Test
↓
Understanding
↓
Specification
↓
Fix
↓
Normal Test
Enter fullscreen mode Exit fullscreen mode
So a Characterization Test is really more of a safety net for changing an unfamiliar system.
What Is Property-Based Testing ?
Up to this point, most of the tests we’ve discussed followed this shape:
Input → Expected Output
Enter fullscreen mode Exit fullscreen mode
For example:
Input: 10
Expected: 20
Enter fullscreen mode Exit fullscreen mode
These are example-based tests — we manually specify the inputs ourselves. But sometimes, instead of checking specific examples, we can define a property of the system instead. Say we have this function:
sort(items)
Enter fullscreen mode Exit fullscreen mode
Instead of only writing:
sort([3,1,2]) == [1,2,3]
Enter fullscreen mode Exit fullscreen mode
we can say: the output must be sorted, and it must contain the same number of elements as the input.
Now a property-based testing framework can generate a large number of inputs and check the property against all of them. Libraries like Hypothesis and fast-check are built for exactly this kind of scenario.
For example, it might generate:
[]
[1]
[3,1]
[10,5,8,2]
[random data...]
Enter fullscreen mode Exit fullscreen mode
and check, for all of them:
isSorted(result) == true
length(result) == length(input)
Enter fullscreen mode Exit fullscreen mode
When Is Property-Based Testing Useful ?
When:
- The number of possible input states is large
- Edge cases are hard to find manually
- You have a clear invariant or property
- The algorithm or domain rule is complex
- You want to check more examples at a lower cost
For instance, in financial systems, you might have a property like:
The sum of all transactions must preserve the balance
Enter fullscreen mode Exit fullscreen mode
Or in serialization:
decode(encode(x)) == x
Enter fullscreen mode Exit fullscreen mode
These kinds of properties can check a huge range of inputs at once.
What Is Fuzz Testing ?
Fuzz Testing (or fuzzing) is a testing technique where you run a program against unexpected, malformed, unusual, or automatically generated inputs, in order to find bugs, crashes, vulnerabilities, or unexpected behavior. OWASP also describes fuzzing as a technique for finding bugs, vulnerabilities, and unexpected behavior through unexpected or corrupted input.
Say we have this API:
POST /users
Enter fullscreen mode Exit fullscreen mode
and we expect it to work correctly with input like:
{
"name": "Ali",
"age": 30,
"email": "[email protected]"
}
Enter fullscreen mode Exit fullscreen mode
In a normal test, we might send inputs like:
age = 30
age = 0
age = -1
Enter fullscreen mode Exit fullscreen mode
But fuzzing can generate far more varied inputs, such as:
age = -999999
age = 999999999
name = ""
name = an extremely long string
email = ""
email = malformed
unusual Unicode
truncated JSON
oversized JSON
null
nested objects
unexpected arrays
Enter fullscreen mode Exit fullscreen mode
The goal isn’t to prove exactly what the output should be — it’s to verify that the system behaves safely and predictably in the face of unexpected input.
What’s the Difference Between Fuzz Testing and Property-Based Testing ?
These two concepts are closely related, and some tools even combine them — but it’s better not to treat them as identical.
In Property-Based Testing, the main question is:
Does a specific property hold across a large number of inputs ?
For example, sort(list) should always return a sorted list for any valid list.
In Fuzz Testing, the question is:
If we feed the system a large number of unexpected or malformed inputs, does it crash, expose a security issue, or behave unexpectedly ?
Put simply:
Property-Based Testing
↓
Generate Inputs
↓
Check Properties
Enter fullscreen mode Exit fullscreen mode
versus:
Fuzz Testing
↓
Generate Unexpected Inputs
↓
Look for Failures / Crashes / Vulnerabilities
Enter fullscreen mode Exit fullscreen mode
That said, the line between the two isn’t perfectly sharp. Some property-based testing tools use fuzzing techniques, and some fuzzers also work on structured inputs with defined properties. Even Hypothesis’s own documentation notes the close relationship between the two.
Where Is Fuzz Testing Useful in Backend Development ?
Fuzzing can be very effective on things like:
API input:
JSON
Query parameters
Path parameters
Headers
Enter fullscreen mode Exit fullscreen mode
Parsers:
JSON parser
XML parser
CSV parser
Custom protocols
Enter fullscreen mode Exit fullscreen mode
Authentication and authorization:
Unexpected input for sessions, tokens, or headers
Enter fullscreen mode Exit fullscreen mode
File uploads:
Empty file
Huge file
Malformed file
Unexpected format
Enter fullscreen mode Exit fullscreen mode
Search and filtering:
Very long strings
Special characters
Unicode
Unexpected combinations
Enter fullscreen mode Exit fullscreen mode
Serialization / deserialization:
Input designed to trigger exceptions, incorrect
behavior, or abnormal resource consumption
Enter fullscreen mode Exit fullscreen mode
Trade-offs of Fuzz Testing
Its advantages include:
- Finding unexpected edge cases
- Discovering crashes
- Uncovering unknown behaviors
- Great for complex inputs
- Can run long-term in CI or dedicated fuzzing environments
And its drawbacks include:
- Designing a good fuzzer can be time-consuming
- Not all random inputs are equally valuable
- Analyzing failures can be difficult
- For some domains, it’s hard to define a clearly “correct” output
- Running fuzzing at scale can consume significant resources
So fuzzing is usually not a replacement for unit or integration tests — it’s a complementary layer for catching a different class of problems.
What Is Mutation Testing ?
If all of your tests are green, how do you actually know your tests are capable of catching bugs ?
Mutation Testing exists to answer exactly that question.
In mutation testing, a tool deliberately introduces small changes into your production code to see whether your tests can detect them. For example, it might change:
if price > 100
Enter fullscreen mode Exit fullscreen mode
into:
if price >= 100
Enter fullscreen mode Exit fullscreen mode
or replace:
+
Enter fullscreen mode Exit fullscreen mode
with:
-
Enter fullscreen mode Exit fullscreen mode
Then your test suite runs.
If the tests fail:
Mutation = Killed
Enter fullscreen mode Exit fullscreen mode
That means the tests successfully caught the change.
If the tests still pass:
Mutation = Survived
Enter fullscreen mode Exit fullscreen mode
That can be a sign of a weakness in your test suite.
So Mutation Testing is less a type of test for your production code, and more a method for evaluating how strong your test suite actually is.
Performance Testing, Load Testing, and Stress Testing
Sometimes the question is: does the system stay stable at 10,000 requests per second ?
This is where we enter the world of Performance Testing.
Load Testing
Checks how the system performs under a specific, expected load. For example:
1,000 concurrent users
100 requests/sec
Response Time < 300ms
Enter fullscreen mode Exit fullscreen mode
The goal of a Load Test is to check system behavior under expected load. Microsoft also defines Load Testing as checking a system’s ability to handle a specific load — such as a given number of concurrent users and how the system responds to them.
Stress Testing
Here you push the load beyond expected capacity to see how the system behaves:
100 req/s
200 req/s
500 req/s
1000 req/s
...
Enter fullscreen mode Exit fullscreen mode
And you check:
- At what point does the system degrade ?
- Does it crash ?
- Does it recover ?
- Does the queue grow unbounded ?
- Does the error rate increase ?
These tests matter a lot for high-traffic backends.
What Is a Smoke Test ?
A Smoke Test is a quick, high-level test that checks whether the system starts up correctly and its core capabilities work.
For example, right after a deploy:
GET /health
Enter fullscreen mode Exit fullscreen mode
or:
POST /login
Enter fullscreen mode Exit fullscreen mode
If these basic paths fail, there’s usually no point running more complex tests.
Smoke tests are typically short and fast, and can run right after deployment.
What Is a Regression Test ?
A Regression Test means re-running previous tests after a change, to make sure existing capabilities still work correctly. When you find a bug, you can write a test that reproduces that exact bug.
For example, say this bug:
Order with quantity = 0
Enter fullscreen mode Exit fullscreen mode
used to result in the order being created successfully.
After the fix:
quantity = 0 → 400 Bad Request
Enter fullscreen mode Exit fullscreen mode
You write a test for this behavior. From then on, if someone reintroduces the same bug through a different change, the test will fail.
The difference between Regression Testing and other test types is that it isn’t defined independently by scope — its focus is more about purpose and when it’s run. For example, say you’ve already tested the CreateOrder capability and everything worked. Now you make a new change — say, updating the discount logic. If you re-run the CreateOrder test to confirm that this new change hasn’t broken previous behavior, that’s a Regression Test.
The key point is that a single test can serve multiple purposes at once. A unit test can also be a regression test, if it’s kept around specifically to prevent an old bug from resurfacing.
The Test Pyramid
A simple way to think about a project’s tests is the Test Pyramid.
The core idea is to have tests at several levels:
Unit Test → small, fast tests
Integration/Feature Test → testing multiple parts together
E2E Test → testing the whole system end-to-end
Enter fullscreen mode Exit fullscreen mode
The usual recommendation is to have many small, fast tests, and fewer large ones. That’s because a unit test might run in a few milliseconds, while an E2E test has to exercise multiple parts of the system and usually costs more in time and resources.
But that doesn’t mean, say, 70% of a project’s tests must be unit tests. The right proportion of each test type depends on the project itself and its specific risks.
So instead of asking, “What percentage of tests should be unit tests ?”
It’s better to ask: “What’s the simplest, fastest test that can give us confidence this part works correctly ?”
For example, if you have a simple calculation, a unit test is probably enough. But if you need to confirm that the API, database, and several services work correctly together, you need an integration or E2E test.
So the Test Pyramid isn’t a fixed rule — it’s just a simple guide for choosing and balancing your tests.
Comparing Different Test Types
The most important distinction between test types is the question each one is really asking:
Unit: Is this logic correct ?
Integration: Do these two components work correctly together ?
Feature: Does this capability work as expected ?
Contract: Do the two services still honor their shared agreement ?
E2E: Does the entire real-world scenario work correctly from start to finish ?
Characterization: What does the current system actually do ?
Property-Based: Does a rule hold across a wide range of inputs ?
Fuzz Testing: Can unexpected input push the system into problematic behavior ?
Mutation: Can my tests actually detect incorrect changes ?
Load Test: How does the system behave under load ?
A Real-World Scenario: From Bug to Test
Imagine that, in an online store, users can occasionally place an order with a negative quantity:
{
"productId": 10,
"quantity": -2
}
Enter fullscreen mode Exit fullscreen mode
and the system creates the order anyway.
Step 1: Reproduce the Bug
First, we write a test that defines the expected behavior:
quantity = -2
Expected: 400 Bad Request
Enter fullscreen mode Exit fullscreen mode
The test fails initially. That’s a good thing — it means we’ve captured the bug as a reproducible test.
Step 2: Find the Bug’s Location
We trace this path:
API
↓
Controller
↓
OrderService
↓
Validator
Enter fullscreen mode Exit fullscreen mode
It turns out the validator isn’t checking the quantity value at all.
Step 3: Add a Unit Test
We write a small test for this rule:
quantity = -2
Expected = Validation Error
Enter fullscreen mode Exit fullscreen mode
Now this test captures the failure more precisely, at the business-rule level.
Step 4: Write a Feature Test
Next, we write a Feature Test:
POST /orders
quantity = -2
Expected: 400
Enter fullscreen mode Exit fullscreen mode
This test confirms the rule is properly enforced along the real API path as well.
Step 5: Fix
We fix the validator:
quantity > 0
Enter fullscreen mode Exit fullscreen mode
Step 6: Run the Test Suite
Now:
Unit Tests ✓
Feature Tests ✓
Integration ✓
Enter fullscreen mode Exit fullscreen mode
If everything’s green, we have much more confidence that both the rule and the API path are correctly fixed.
Step 7: Prevent Regression
From now on, any change to order validation that reintroduces quantity = -2 will make the test fail.
This is exactly where automated testing shows its real value:
The test didn’t just find the current bug — it also reduced the cost of that same bug happening again.
What Happens If We Fuzz This Same API ?
Let’s take it one step further. We found the quantity = -2 bug using a specific test.
But is that the only problematic case ?
These might also produce interesting behavior:
quantity = -999999999
quantity = 0
quantity = 2147483647
quantity = null
quantity = "2"
quantity = []
quantity = {}
Enter fullscreen mode Exit fullscreen mode
In a fuzz test, we don’t have to manually define every possible input case — tools can generate a wide variety of unexpected inputs to check that the system behaves correctly across all of them. For example:
For every generated request:
System must not crash
AND
invalid quantity must not create an order
Enter fullscreen mode Exit fullscreen mode
Now, if the fuzzer finds a specific input that triggers an exception or unexpected behavior, that exact input can be turned into a fixed, reproducible test.
This is a very practical workflow:
Fuzzing
↓
Unexpected Input
↓
Bug Found
↓
Minimize / Reproduce
↓
Regression Test
↓
Permanent Safety Net
Enter fullscreen mode Exit fullscreen mode
In Property-Based Testing, when a specific input causes a test to fail, property-based frameworks can shrink that input down to the smallest example that still triggers the failure. This is called shrinking.
How to Write Tests That You Won’t Hate Them ?
Writing a test is easy. Writing a maintainable test is harder.
A few key principles:
1. Test behavior, not implementation
Instead of a test depending on this kind of internal structure:
OrderService
→ Method A
→ Method B
→ Method C
Enter fullscreen mode Exit fullscreen mode
it’s better to check observable behavior as much as possible. If you refactor the implementation, tests shouldn’t break for no reason. Microsoft’s own unit testing guidance also emphasizes readability and resilience against unnecessary changes.
2. Tests should be readable
If you have to read a test’s implementation just to understand what the test does, the test is probably too complicated. A name like:
CreateOrder_WithInvalidQuantity_ReturnsBadRequest
Enter fullscreen mode Exit fullscreen mode
is usually more meaningful than something like:
ShouldCallValidatorThenRepositoryAnd...
Enter fullscreen mode Exit fullscreen mode
A test’s name should describe the scenario.
3. Take edge cases seriously
A test that only checks the happy path isn’t enough. For instance, testing:
quantity = 1
Enter fullscreen mode Exit fullscreen mode
is fine — but what happens with these ?
quantity = 0
quantity = -1
quantity = MAX_INT
quantity = null
Enter fullscreen mode Exit fullscreen mode
Edge cases are exactly where bugs hide.
Does 100% Code Coverage Mean the System Is Fully Tested ?
This is one of the most common misconceptions in automated testing. Say:
Code Coverage = 100%
Enter fullscreen mode Exit fullscreen mode
Does that mean the system is bug-free ?
No. Coverage only tells you how much of your code ran while your tests were executing — not whether the important behaviors were actually tested correctly. For example, a test might simply do this:
function calculatePrice() {
...
}
Enter fullscreen mode Exit fullscreen mode
and just call the function without asserting anything meaningful about the result. In that case, coverage looks high, but confidence in correct behavior stays low. So it’s better to treat coverage as a signal, not a final goal.
The more important question is whether the system’s core business rules and failure modes have actually been tested.
What Is a Test Double ?
A Test Double means using a temporary stand-in for a real dependency, used specifically during testing.
The common types are:
1. Stub: returns a specific piece of fixed data:
getUser() → returns User(id=10)
Enter fullscreen mode Exit fullscreen mode
A stub is essentially data a test can’t run correctly without.
2. Mock: used to verify invocations. For example:
PaymentService.charge()
Enter fullscreen mode Exit fullscreen mode
must be called exactly once, or the test fails.
3. Fake: a simpler but still realistic implementation of a dependency. For example:
InMemoryUserRepository
Enter fullscreen mode Exit fullscreen mode
used in place of a real database.
4. Spy: records information about a dependency’s real behavior during a test run. For instance, letting EmailService run as normal during a test, then checking how many times sendEmail was called and with what parameters.
Worth noting: these terms don’t always mean exactly the same thing across different frameworks and sources. So what matters more than the label is understanding what role a given test double actually plays in your tests.
What Test Should You Write, and Where ?
For quick decision-making, you can use this rule of thumb:
If you have simple business logic:
Unit Test:
Discount calculation
Tax calculation
Permission rules
Validation rules
Pricing
Enter fullscreen mode Exit fullscreen mode
If you’re interacting with a database or infrastructure:
Integration Test:
Repository
Database
Redis
Message broker
File system
Enter fullscreen mode Exit fullscreen mode
If you want to verify an API capability:
Feature / Functional Test:
POST /orders
POST /login
GET /profile
Enter fullscreen mode Exit fullscreen mode
If two microservices communicate with each other:
Contract Test:
Order Service ↔ Payment Service
Enter fullscreen mode Exit fullscreen mode
If the whole business flow matters:
E2E Test:
Login → Order → Payment → Confirmation
Enter fullscreen mode Exit fullscreen mode
If you’re dealing with untested legacy code:
Characterization Test:
Current Behavior → Capture → Refactor Safely
Enter fullscreen mode Exit fullscreen mode
If the input space is very large:
Property-Based Test:
Generate Many Inputs
↓
Check Invariant
Enter fullscreen mode Exit fullscreen mode
If unexpected or malformed inputs matter:
Fuzz Testing:
Generate Unexpected Inputs
↓
Execute System
↓
Find Crash / Failure / Unexpected Behavior
Enter fullscreen mode Exit fullscreen mode
If you want to know the system’s capacity:
Load / Performance Test:
100 → 500 → 1000 req/s
Enter fullscreen mode Exit fullscreen mode
What Does a Professional Test Suite Look Like ?
A professional backend probably has something structured like this:
tests/
│
├── unit/
│ ├── pricing/
│ ├── validation/
│ └── authorization/
│
├── integration/
│ ├── database/
│ ├── redis/
│ └── messaging/
│
├── feature/
│ ├── orders/
│ ├── users/
│ └── payments/
│
├── contract/
│ └── payment-service/
│
├── e2e/
│ └── checkout/
│
├── characterization/
│ └── legacy-order/
│
├── property/
│ └── pricing/
│
└── fuzz/
├── api/
└── parsers/
Enter fullscreen mode Exit fullscreen mode
This structure is just one example — you don’t need to implement it exactly this way. What matters is that the team knows exactly what risk each test exists to cover.
Feedback Loop Matters More Than Test Count
One common mistake is measuring a test suite’s success by the number of tests it has. For example, 5,000 tests sounds impressive. But if running them takes 45 minutes, developers probably won’t run them constantly. On the other hand, 1,500 tests that run in 30 seconds might deliver far more value throughout the development cycle. That’s exactly why one of the most important traits of a professional test suite is fast feedback.
The lower the scope at which you can write an appropriate test, the faster your feedback tends to be, and the easier failures are to diagnose.
The Arrange/Act/Assert Pattern
A very common structure for writing tests is the Arrange/Act/Assert pattern. This structure improves readability in small tests and even in higher-level ones. It works like this:
Arrange: set up the data and dependencies
Act: run the code you want to test
Assert: verify the result
For example:
Arrange:
price = 1,000,000
customer = Premium
Enter fullscreen mode Exit fullscreen mode
Act:
result = calculateDiscount(price, customer)
Enter fullscreen mode Exit fullscreen mode
Assert:
result == 100,000
Enter fullscreen mode Exit fullscreen mode
Choose Tests Based on Risk, Not Habit
The most important thing to take away from this article isn’t that unit tests are good or E2E tests are bad — that view is overly simplistic. The right question isn’t which type of test is “the best.” It’s: for a specific risk, which test gives you enough confidence at the lowest cost ?
Wrapping Up
Automated Testing isn’t just about writing a handful of unit tests to boost code coverage. A professional test suite should be a balanced collection of tests, each covering a different kind of risk.
Unit Tests are for small, fast logic. Integration Tests are for the connections between units. Feature Tests are for the system’s capabilities. Contract Tests are for the boundaries between microservices. E2E Tests are for critical real-world scenarios. And Characterization Tests are for understanding and safely working with legacy code.
Alongside these, Property-Based Testing and Fuzz Testing offer different ways to uncover edge cases and unexpected behavior, and Mutation Testing helps you understand just how good your test suite actually is at catching incorrect changes.
A good test isn’t one that runs the most code — it’s one that covers the most important risk at the lowest possible cost.
Once this mindset becomes part of how you design a test suite, tests stop being a side cost and become part of your architecture and development process.
One Final Question
If tomorrow you had to refactor a legacy service that has almost no tests, what’s the very first test you’d write ? What has your experience been like on real-world projects ?
Further Reading
- Martin Fowler — Test Pyramid
- Martin Fowler — The Practical Test Pyramid
- Martin Fowler — On the Diverse And Fantastical Shapes of Testing
- Microsoft Learn — Testing in .NET
- Microsoft Learn — Unit Testing Best Practices
- Microsoft Learn — Integration Tests in ASP.NET Core
- Pact — Contract Testing
- OWASP — Fuzzing
- Hypothesis — Property-Based Testing
- fast-check — Property-Based Testing
- Martin Fowler — Legacy Seam

