Introduction
When we read about Testing, common thing we see is Testing Pyramid

The idea is that Unit Tests should form the foundation of our testing strategy — fast, numerous, and covering the smallest parts of the system. Above them, we have fewer but slightly slower Integration Tests, and at the very top, the smallest portion: End-to-End (E2E) Tests.
Makes sense right?
So let’s dig into details!
What is an Unit Testing?
Unit testing is a software testing method where individual, smallest testable parts of a program (called units, e.g., functions, methods, or classes) are tested in isolation to verify they work as intended.
Whats an Integration Testing?
Integration testing is a software testing level where individual components or modules are combined and tested together to verify that they work correctly as a group and that interactions between them behave as expected.
What’s E2E Testing?
End-to-end (E2E) testing is a testing approach where you verify an application’s entire workflow from start to finish, simulating real user scenarios.
First problems
The definitions of Unit and Integration Testing often don’t give us much practical guidance. They use vague terms like unit, module, or component, which leave plenty of room for interpretation.
So, let’s ask ourselves a few questions:
- In a microservice, should we test every single thing separately, like each getter or setter (the “smallest testable part”)?
- If one class or function calls another, does that automatically make it an integration test?
- Since private methods can’t be reliably tested, should we focus only on public ones?
- If we focus on public methods only, then the public interface of an API is just its endpoints, right?
- If we test only through the API’s public interface, should we mock private parts, or connect to a real database?
- If we mock the database, how can we be sure we’re testing the actual flow of the application, rather than just the behavior we mocked?
- If the database schema belongs to the service, does that make it part of a unit?
If you’re interested in more thought-provoking questions like these, I recommend Martin Fowler’s articles, where he also introduces concepts such as:
- Shallow / narrow integration testing
- Broad integration testing
- Solitary unit testing
- Sociable unit testing
📚 References:
Test writing
If we focus on a unit-based approach to testing the flow of “Creating a User”, we might come up with several tests:
- The endpoint is registered at the correct URL.
- The correct model binding is in place.
- The model is validated properly.
- The validator is called by some business logic or controller class.
- The endpoint calls the business logic class.
- The business logic class calls the data layer.
- The correct data is returned from the repository.
- Data is correctly transformed (e.g., from entity to response).
However, in this scenario, each step depends on the previous one. That’s why my first choice would be to write a black-box test that calls the API with a model and checks if the correct response is returned. This way, a single test already covers most of the flow.
What’s important here is that this is a consumer flow — if the API is deployed, this is exactly how a user is created (whether by an external service or a web app). We want this test to work continuously without frequent modifications.
When you don’t have to constantly tweak these kinds of tests, you gain confidence that there’s no breaking change in your API.
Once you have your basic flow tested through the API, you should also check your code coverage for the new code and ask yourself:
- Shall I test it using API calls?
- Should I remove lines of code that can never be reached through the regular flow?
Example: If we modify the user creation flow so the validator checks for an existing user before hitting the repository, then the repository method returns FirstOrDefault. In this setup, it can never return null in our modification flow, because the validator fails first. The only way to reproduce a null is by mocking — which might indicate that the code is unnecessary.
- Should I create a mocked environment to test hard-to-reach behavior?
Example: If we call an external service to retrieve some data and want to test retry logic, calling the actual API endpoint might obscure what we want to test. Testing the specific code responsible for retries directly would make more sense.

How to approach testing?
Let’s get back to the Testing Pyramid and review what we have learned so far. The best tests (on top), are End-to-End (E2E) tests, because they:
- Reflect real scenarios.
- Are easy to run from a user’s perspective.
- Only need changes if the user-facing flow changes.
However, E2E tests are also slow, flaky, and require a full infrastructure and environment to run and can be run late in development process
With that in mind — and following the Shift Left principle (bringing feedback and problem detection earlier in the development process) — most tests should be on the service level.
It doesn’t really matter if they’re called Unit, Integration, or Shallow Integration — what matters is that they can be run:
- On a microservice level.
- Locally, or in a CI pipeline, without heavy setup.
- Without depending on full production-like infrastructure.
If a test connects to a database, that’s fine — it’s not your production database, and it doesn’t contain production data that could cause conflicts. We can test those parts later in more environment-specific stages.
What can’t be tested reliably at the service-test stage is external communication — but this is exactly where Contract Testing shines. If your service test mocks an external call, that interaction should also be defined in a contract test to ensure it matches the real-world API or service.
Other aspects — such as calls to external resources, database connectivity, or integration with other services in your environment — can be validated in E2E tests. These tests exercise the actual behavior end-to-end, and unlike some other E2E scenarios, they tend to be less flaky and carry fewer downsides. For example, if one call to the database works in a deployed environment, chances are the rest will too.