Skip to content
Go back

Software Component Integration Testing

In the previous post, we looked at testing from the perspective of a single software component: how to check the logic inside it quickly, independently, and in isolation. That is the territory of Unit Testing, where dependencies are usually isolated so we can focus on the behavior of the component itself.

But a component rarely lives on its own. In a real system, it has to work with a database, an API, a message broker, a filesystem, or other services. And having every unit test pass still does not guarantee that those pieces will work together correctly. That is the problem Integration Testing addresses.

Where a unit test asks “is the logic inside this component correct?”, an integration test asks a different question: “does this component actually work when it is connected to the dependencies it relies on?”

From here, the scope of testing widens: from one component in isolation to how that component interacts with the world around it.

Integration Testing is not a bigger Unit Test

It is fairly common to think of an integration test as a unit test that simply runs more things at once. That is not quite right.

A unit test tries to isolate a component from its dependencies. We might mock the database, an external API, or a message broker so we can focus on the component’s logic in isolation.

An integration test does the opposite: it exercises the component against its actual dependencies.

Take a service that exposes an API for creating charts and stores them in PostgreSQL. In a unit test, we can mock PostgreSQL to check validation, business rules, and data transformation. Those tests tell us whether the logic inside the service is correct, but they do not answer questions such as:

  • Does the service actually connect to PostgreSQL?
  • Do the queries match the database schema?
  • Is the data really written to the database?
  • Does the response match the expected contract?

That is exactly what an integration test needs to cover.

So the goal of Integration Testing is not to re-test all the logic already covered by unit tests. Instead, every public function of the component should be exercised by at least one integration test, without re-checking every branch inside it.

That is also why a sensible testing strategy usually has many unit tests, fewer integration tests, and even fewer end-to-end tests. Each layer answers a different question.

An integration test should treat the component as a black box

One good way to build integration tests is through black-box testing.

The test does not need to know how the component is implemented internally. It only cares about its inputs and outputs:

Input → Component → Output

For a REST API, an integration test only needs to know which request goes in and which response is expected. It does not need to know how many classes, functions, or modules that request passes through internally.

This gives integration tests an important property: they depend very little on implementation details.

Today, the service might be written in TypeScript. Tomorrow, the team might rewrite it in Go or Java. As long as the public API and behavior remain the same, the integration tests should not need to change.

That is a meaningful difference from tests that are tightly coupled to the implementation.

An integration test can also be written in a different programming language or testing framework from the application. The test does not care about the source code inside; it communicates with the component through its public interface.

And when a test is genuinely designed as a black box, we do not necessarily have to wait for the implementation to be finished before writing it.

Start from behavior instead of implementation

This is where BDD and ATDD become useful.

Instead of starting a feature with the question, “which function do we need to write?”, the team can start with a different one:

“How should this feature behave?”

For example, rather than saying that we need to implement a createChart() function, we can define the behavior first:

When a user provides valid chart information and sends a create request, the system should create the chart and return HTTP 201.

That behavior can become a specification. The specification can then become an integration test and, at the same time, serve as the acceptance test for the feature.

This is one of the central ideas behind BDD and ATDD: the behavioral specification becomes the basis for the test.

This approach also moves testing to the left in the development lifecycle — what is commonly called shift-left testing.

Integration tests are no longer something we write only after the implementation is finished. The team can agree on the behavior and write the specification as soon as the feature is defined, allowing implementation and testing to proceed in parallel.

It also creates a shared understanding between developers, testers, product people, and other stakeholders.

Everyone agrees not just that “the feature is implemented”, but on what correct behavior for that feature actually means.

You do not need every error and edge case at the integration level

A behavioral specification should focus on scenarios that are meaningful at the integration level.

Usually, we start with the happy path — the case where the system behaves normally and the input is valid — and then add the failure scenarios and edge cases that matter.

For a chart creation API, we might have one scenario for successfully creating a chart and another for a request with missing mandatory parameters.

But we do not need to push every conceivable invalid input into the integration suite. Small validation rules, boundary values, and complex input combinations belong mostly in unit tests.

Integration tests should focus on confirming that the component and its dependencies actually work together.

If we try to check everything through integration tests, the suite quickly becomes large, slow, and difficult to maintain.

Gherkin is only one way to describe behavior

A popular format for writing behavioral specifications is Gherkin, with its familiar Given → When → Then structure.

For example:

Feature: Create chart

Scenario: Create a new chart successfully

Given chart layout id is 1
And chart type is "line"
And X-axis categories shown count is 10
And fetched row count is 1000

When I create a new chart

Then I should get the chart given above
And status code should be 201

Given describes the context or starting state, When describes the action taking place, and Then describes the expected result.

The important point is that BDD is not the same thing as Gherkin.

Gherkin is simply one way of expressing a behavioral specification. We can apply BDD without using Gherkin at all, or use a different testing framework to implement the integration tests.

What matters is not whether the team has .feature files. What matters is whether the behavior of the system is clearly defined and can be verified automatically.

A feature should be testable from end to end

There is an interesting principle when designing features for integration testing: a feature should be testable from input to output.

Imagine we are building a data exporter microservice. It consumes Avro messages from Kafka and exports them to Pulsar.

If the first feature is simply:

“Consume Avro binary messages from Kafka.”

the integration test runs into a problem. We can check that the service reads a message from Kafka, but what is the output of the feature? What should we compare it against?

A feature such as:

“Export a message from Kafka to Pulsar.”

has a complete flow:

Kafka → Service → Pulsar

Now we can put a message into Kafka, observe it appear in Pulsar, and verify the result.

This is the idea behind a Walking Skeleton.

Rather than building many internal pieces first and only later figuring out how to test the whole system, we first build a small vertical slice that works end to end.

We can then add other capabilities around it: filtering, type conversion, expression transformation, TLS, CSV export, JSON export, and so on.

A walking skeleton is essentially the first working structure of the system. Once it can run from input to output, we can start adding more functionality around it.

The practical benefit is significant: from the earliest features onward, the team already has a system that can run, be tested, and be demonstrated.

Integration tests should not be tightly coupled to a framework

Another thing worth considering is the use of framework-specific testing tools.

Many frameworks provide their own testing tools, and at first this is convenient because developers are already familiar with the framework.

But integration tests have one important characteristic: they should remain as independent of the implementation as possible.

If a Python microservice uses a framework-specific testing tool and the service is later rewritten in Go or Java, the integration tests may need to be rewritten as well.

If the tests are written as black-box tests through the API or another public interface, they can outlive the current implementation.

This is also why generic BDD tools such as Behave can be useful in a microservices environment.

Service A might be written in Python, Service B in Java, and Service C in Node.js. If they all expose public APIs, the integration tests can follow the same approach.

It also reduces the learning burden for QA and test engineers. Instead of learning a different testing framework for every microservice, the team can use one tool and a consistent way of writing tests.

For APIs, Postman can also be used for integration testing

We do not always need a BDD framework.

For API microservices, Postman can also be used to build integration tests.

For example, we can send:

POST /sales-items

and check that the response has status code 201 and that the name and price of the sales item are correct.

The ID of the newly created object can then be stored and used in the next request:

GET /sales-items/{{salesItemId}}

The test can now verify not just individual APIs, but an entire flow:

Create → Store ID → Retrieve → Verify

The Postman collection can then be exported as JSON and run in CI with Newman.

What matters here is not whether Postman or Behave is the “correct” tool. The tool is only a means to an end.

What matters is that the integration test verifies behavior through the public interface and can run automatically and repeatably in CI.

Integration testing applies to the UI too

The same thinking applies to web UI testing.

Take a simple feature:

Feature: Greet user

Scenario: Greet user successfully

Given there is "John Doe" entered in the input field

When I press the submit button

Then I am greeted with text "Hello, John Doe"

Here, we do not care whether the button is implemented in React or Vue, or whether state is managed by Redux or another library.

What we care about is the behavior the user can observe:

Input → Action → Result

Tools such as TestCafe or Cypress can be used to implement these tests. Gherkin can also be used if the team wants to keep the behavioral specification separate from the implementation.

Integration tests need a real environment

Once we decide to test against actual dependencies, a practical question appears: where do those dependencies run?

Take a sales-item-service that uses MySQL. An integration test cannot simply start the service and run if the database is not available.

A simple environment might look like this:

Integration Test
       ↓
Sales Item Service
       ↓
     MySQL

If the service also uses Kafka, the environment becomes more involved:

Integration Test
       ↓
Sales Item Service
    ↙        ↘
 MySQL      Kafka
              ↓
         Zookeeper

For containerized applications, Docker Compose is a straightforward way to create an integration testing environment.

The CI pipeline can then follow a flow like:

Start environment
        ↓
Wait for dependencies
        ↓
Run integration tests
        ↓
Collect results
        ↓
Shutdown environment

One detail that is easy to miss is that a running container does not mean the service is ready.

The database may have started but still be initializing. The application container may be running while the HTTP server is not yet accepting requests.

So before running the integration tests, we need to make sure the dependencies are actually ready.

Once the tests finish, the environment can be shut down and recreated from scratch on the next run.

This makes the test environment more predictable and reduces its dependence on state left behind by previous runs.

What is integration testing really checking?

In the end, a testing strategy can be seen as a widening of scope.

A unit test starts inside a single component:

Is the logic inside this component correct?

An integration test widens the scope beyond the component:

Does this component actually work correctly with its dependencies?

And when we widen the scope to the whole system:

Does the entire system carry out the business flow correctly from end to end?

That will be the question for End-to-End Testing.

So an integration test is not a bigger unit test. It exists to check a different kind of risk — the risk that sits at the boundaries between parts of the system.

Does the database actually receive the right data?

Does the API actually honor its contract?

Does the message actually travel through the broker to its destination?

Does the service actually understand the interface exposed by its dependency?

These are questions that are difficult to answer with mocks alone.

And that is why integration testing exists.

Just as with unit testing, the goal is not to produce as many tests as possible. The goal is to put the right kind of test in the right place.

Unit tests help us trust that the logic inside a component is correct.

Integration tests help us trust that the components actually work together.

The next step widens the view once more: from components and dependencies to the whole system and the user journey — End-to-End Testing.

Previous Post
Next Post