Integration Testing
June 4, 2025
In order to effectively test an application, it's crucial to know that it properly interacts with its dependencies. Verifying this can be hard for a number of reasons. Frequently, all systems associated with an application are not owned by a single team. Other times, changes are occurring fast and frequently, making it difficult to keep all the systems up to date. It's important to have systems in place that ensure any application that has dependencies is integrated properly. This is applicable for all types of dependencies, whether it be an HTTP request to another microservice, or a call to a database. If the microservice changes its endpoints, or the DBMS changes, it is important to know whether the application can handle these changes or if intervention is needed. Integration testing allows for this process to be automated, and ensures proper communication between an application and its dependencies.
Test Containers
Using Docker TestContainers, it is very easy to spin up instances of any dependency that is available to be run via Docker. In order to make sure that an application interacts with another service properly, TestContainers can be used to manage an instance of another service for the duration of a test suite. TestContainers can spin up at the beginning of the suite, allowing for the tests to make real network connections to the resources, run real HTTP requests, and receive accurate responses. With this, no mocking is necessary, and greater certainty of application performance is achieved. In the following examples, I'll use a Postgres Docker TestContainer in order to check the integration of a simple microservice with a database.
Homes Service
For the duration of the article, I'll be referencing code from a very basic NestJS project that I've developed. The sole purpose of this service is to manage homes. This service can fetch all the homes that are currently stored in the database, fetch homes based on specific criteria, or "reserve" a home, which really doesn't do anything. The service is kept brutally simple to focus on the testing.
@Controller('homes') export class HomesController { constructor(private homesService: HomesService) {} @Get() public async getHomes() { return await this.homesService.getHomes(); } @Get('/:city/:street/:zip') public async findHome(@Param() findHomeDto: FindHomeDTO) { return await this.homesService.findHome( findHomeDto.city, findHomeDto.street, findHomeDto.zip, ); } @Post('/reservation/:city/:street/:zip') public async reserveHome(@Param() reserveHomeDto: ReserveHomeDTO) { return await this.homesService.reserveHome( reserveHomeDto.city, reserveHomeDto.street, reserveHomeDto.zip, ); } }
@Injectable() export class HomesService { constructor( @InjectRepository(Home) private homesRepository: Repository, ) {} public async getHomes() { return await this.homesRepository.find(); } public async findHome(city: string, street: string, zip: string) { const home = await this.homesRepository.findOneBy({ street, city, zip, }); if (home) { return home; } else { throw new NotFoundException('Home does not exist'); } } public async reserveHome(city: string, street: string, zip: string) { const home = await this.homesRepository.findOneBy({ street, city, zip, }); if (!home) { throw new NotFoundException('Home does not exist'); } } }
Databases and Running Migrations on TestContainers
TypeORM is used to manage database connections, entities, and migrations. In this application, there is a singular home entity
@Entity() export class Home { constructor( street: string, city: string, zip: string, pricePerNight: number, ) { this.street = street; this.city = city; this.zip = zip; this.pricePerNight = pricePerNight; } @PrimaryGeneratedColumn() id: number; @Column({ type: 'varchar', name: 'street', nullable: false }) public street: string; @Column({ type: 'varchar', name: 'city', nullable: false }) public city: string; @Column({ type: 'varchar', name: 'zip', nullable: false }) public zip: string; @Column({ type: 'int', name: 'price_per_night', nullable: false }) public pricePerNight: number; toJson() { return { id: this.id, street: this.street, city: this.city, zip: this.zip, pricePerNight: this.pricePerNight, }; } }
In order to generate database tables, TypeORM was used to create migrations automatically based on the entity
import { MigrationInterface, QueryRunner } from 'typeorm'; export class Init1748110788902 implements MigrationInterface { name = 'Init1748110788902'; public async up(queryRunner: QueryRunner): Promise{ await queryRunner.query( `CREATE TABLE "home" ("id" SERIAL NOT NULL, "street" character varying NOT NULL, "city" character varying NOT NULL, "zip" character varying NOT NULL, "price_per_night" integer NOT NULL, CONSTRAINT "PK_012205783b51369c326a1ad4a64" PRIMARY KEY ("id"))`, ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE "home"`); } }
These migrations are run against the database upon startup, to ensure the database is in a consistent state and allows our application to interact with it properly. When running tests, the goal is to have the database schema identical to the schema that exists in the primary database. As such, the same TypeORM migrations are ran on the TestContainers database to ensure consistency
describe(HomesService.name, () => { jest.setTimeout(60000); let homesService: HomesService; let container: StartedPostgreSqlContainer; let homeRepo: Repository<Home> let datasource: DataSource; beforeAll(async () => { container = await new PostgreSqlContainer() .withDatabase('contract_testing') .withUsername('postgres') .withPassword('postgres') .start(); }); afterAll(async () => { await container.stop(); }); beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [ TypeOrmModule.forRoot({ type: 'postgres', host: container.getHost(), port: container.getPort(), username: container.getUsername(), password: container.getPassword(), database: container.getDatabase(), migrationsRun: true, entities: [Home], migrations: [Init1748110788902], }), TypeOrmModule.forFeature([Home]), ], controllers: [], providers: [HomesService], }).compile(); homesService = moduleRef.get(HomesService); homeRepo = moduleRef.get(getRepositoryToken(Home)); datasource = moduleRef.get(DataSource); }); afterEach(async () => { await homeRepo.deleteAll(); }); })
The TestContainer setup begins in the beforeAll() method, creating the postgres database. In the beforeEach() method, the database connection is initialized via TypeORM. Because the path to the migrations file is specified, and the migrationsrun field set to true, the migrations that are run on the primary database are also run on the test database. To connect to the test database, the container connection information is used in the datasource
Seeding TestContainers
With the database set up, the next step is to seed it. There are a number of different ways to do so. One would be to seed on a per-test basis, making it extremely clear what the tests should return
it('Fetches homes properly', async () => { const home = new Home('Street', 'City', 'Zip', 123); await homeRepo.save([home]); const homes = await homesService.getHomes(); expect(homes).toMatchObject([home.toJson()]); });
Another way would be to have a seed script initialize the database with random data. In order to develop locally, I have developed a seed script that can run on a local database to populate some example homes. This can also be used the test container, so that the seeding script works both for testing and local development
it('properly fetches a lot of homes', async () => { await runSeed(datasource); const homes = await homesService.getHomes(); expect(homes).toHaveLength(20); });
const streets = [ '123 Oak Street', '456 Maple Avenue', '789 Pine Lane', '321 Elm Drive', '654 Cedar Court', '987 Birch Boulevard', '147 Willow Way', '258 Spruce Street', '369 Aspen Avenue', '741 Hickory Hill', '852 Dogwood Drive', '963 Magnolia Manor', '159 Cherry Circle', '357 Poplar Place', '486 Sycamore Street', ]; const cities = [ 'New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix', 'Philadelphia', 'San Antonio', 'San Diego', 'Dallas', 'San Jose', 'Austin', 'Jacksonville', 'Fort Worth', 'Columbus', 'Charlotte', 'San Francisco', 'Indianapolis', 'Seattle', 'Denver', 'Boston', ]; const zipCodes = [ '10001', '90210', '60601', '77001', '85001', '19101', '78201', '92101', '75201', '95101', '73301', '32099', '76101', '43085', '28201', '94102', '46201', '98101', '80202', '02101', ]; function getRandomElement<T>(array: T[]): T { return array[Math.floor(Math.random() * array.length)]; } function generateRandomPrice(): number { // Generate prices between $50 and $500 per night return Math.floor(Math.random() * 450) + 50; } async function seedHomes(ds: DataSource, count: number = 10) { const homeRepository = ds.getRepository(Home); console.log(`Seeding ${count} homes...`); const homes: Home[] = []; for (let i = 0; i < count; i++) { const home = new Home( getRandomElement(streets), getRandomElement(cities), getRandomElement(zipCodes), generateRandomPrice(), ); homes.push(home); } try { console.log('here!'); await homeRepository.save(homes); console.log(`Successfully seeded ${count} homes!`); } catch (error) { console.error('Error seeding homes:', error); throw error; } } export async function runSeed(ds: DataSource) { try { if (!ds.isInitialized) { await ds.initialize(); } console.log('Database connection established'); // Check if homes already exist const homeRepository = ds.getRepository(Home); const existingCount = await homeRepository.count(); if (existingCount > 0) { console.log( `Database already has ${existingCount} homes. Skipping seed.`, ); return; } // Seed 15 random homes await seedHomes(ds, 20); } catch (error) { console.error('Seeding failed:', error); } } // Run the seed if this file is executed directly if (require.main === module) { runSeed(dataSource).catch(console.error); }
With these approaches, the communication between our application and a real database can be tested in a repeatable, maintainable manner
Contract Testing
Contract testing is an easy way to screen any basic initial problems that would occur when communicating with another application over a standard such as HTTP. If another web service offers an HTTP API that your application integrates with, it makes sense that developers would want to be able to test the interaction between the two. Integration tests that require another whole instance of the application running are complex, especially if the dependent application has many dependencies itself. This is where contract testing comes in. Contract testing is a way to ensure that one service implements the contract of the service it is calling properly: making HTTP requests with the proper path parameters, the proper types in the JSON body, etc. This can all occur without needing a full instance of an application running. A service can simply generate its contract, a definition of what it expects to receive, distribute it to other services, and then those services can run tests using the contract
OpenAPI Specification
One common API contract format is the OpenAPI standard format. This is either a YAML or JSON file which describes in varying levels of detail what the API expects, potential response codes, and potential responses. These can be used by applications for contract testing. There are many tools to generate OpenAPI specifications for existing APIs, but here is an example of the way that it can be done in NestJS
Contract Generation
async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({transform:true})); const config = new DocumentBuilder() .setTitle('Contract Testing') .setDescription('Contract Testing Application') .setVersion('1.0') .addTag('homes'); await OpenApiNestFactory.configure(app, config, { webServerOptions: { enabled: true, path: 'api-docs' }, fileGeneratorOptions: { enabled: true, outputFilePath: './openapi.json' }, }); await app.listen(process.env.PORT ?? 3000); } bootstrap();
With this approach, every time the application runs it generates an OpenAPI specification in JSON format with the most up-to-date representation of the application. Here is an example for the homes app
{ "openapi": "3.0.0", "paths": { "/homes": { "get": { "operationId": "getHomes", "parameters": [], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Home" } } } } } }, "tags": [ "Homes" ] } }, "/homes/{city}/{street}/{zip}": { "get": { "operationId": "findHome", "parameters": [ { "name": "street", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "city", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "zip", "required": true, "in": "path", "schema": { "type": "string" } } ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Home" } } } } }, "tags": [ "Homes" ] } }, "/homes/reservation/{city}/{street}/{zip}": { "post": { "operationId": "reserveHome", "parameters": [ { "name": "street", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "city", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "zip", "required": true, "in": "path", "schema": { "type": "string" } } ], "responses": { "201": { "description": "" } }, "tags": [ "Homes" ] } } }, "info": { "title": "Contract Testing", "description": "Contract Testing Application", "version": "1.0", "contact": {} }, "tags": [ { "name": "homes", "description": "" } ], "servers": [], "components": { "schemas": { "Home": { "type": "object", "properties": { "id": { "type": "number" }, "street": { "type": "string" }, "city": { "type": "string" }, "zip": { "type": "string" }, "pricePerNight": { "type": "number" } }, "required": [ "id", "street", "city", "zip", "pricePerNight" ] } } } }
HTTP Server with Prism and OpenAPI
In order to utilize the OpenAPI specification to its full extent, there are services such as Prism, which can spin up HTTP servers based on existing OpenAPI specifications. These servers can be as simple as serving static content that you define in your spec for each response, or even generating dynamic content based on the specification provided. Prism provides a Docker container which allows for easily spinning up an instance of an HTTP server
services: prism: profiles: ['prism'] image: stoplight/prism command: 'mock -d -h 0.0.0.0 --json-schema-faker-fillProperties=false /tmp/homesapi.json' volumes: - ./homesapi.json:/tmp/homesapi.json:ro ports: - '4010:4010'
In this example, "prism -d" signifies prism should generate dynamic responses from the API. It runs on host 0.0.0.0, and the homesapi.json file is provided as the specification. Once running, tests can send real http requests to the homes service, and get valid responses back.
Profiles Service
To show the contract testing in action, I have created another extremely basic and contrived service which makes an HTTP request to the homes service, called the profiles service. The methods are extremely simple as seen below
import { Controller, Get, Param } from '@nestjs/common'; import { ProfilesService } from './profiles.service'; import { ProfileHomeDto } from './profiles.dto'; @Controller('profile') export class ProfilesController { constructor(private profileService: ProfilesService) {} @Get('/:id') public async getProfile(@Param('id') id: number) { return await this.profileService.getProfile(id); } @Get('/:id/:city/:street/:zip') public async findProfileHome(@Param() param: ProfileHomeDto) { return await this.profileService.findProfileHome( param.id, param.city, param.street, param.zip, ); } }
@Injectable() export class ProfilesService { constructor( private httpService: HttpService, private homesServiceBaseUrl: string, ) {} names = ['someone1', 'someone2', 'someone3', 'someone4']; public async getProfile(id: number) { if (id >= this.names.length) { throw new BadRequestException('no users found with that ID'); } const url = `${this.homesServiceBaseUrl}/homes`; const res = await firstValueFrom(this.httpService.get(url)); const homes: any[] = res.data; const profileHomes = homes.filter((home) => { return home.id === id; }); return { name: this.names[id], homes: profileHomes, }; } public async findProfileHome( id: number, city: string, street: string, zip: string, ) { if (id >= this.names.length) { throw new BadRequestException('no users found with that ID'); } try { const url = `${this.homesServiceBaseUrl}/homes/${city}/${street}/${zip}`; const res = await firstValueFrom(this.httpService.get(url)); const home: any = res.data; return { name: this.names[id], home: home, }; } catch (e) { console.error(e); throw new BadRequestException(); } } }
basic HTTP calls to the homes service fetch data, and return it alongside some static data.
Writing the contract tests for Profiles Service
Writing the contract tests for the profiles service is quite simple. All that is required is to spin up a mock homes API via prism, and then swap the url for http requests to point to the locally hosted version of the service. Prism will handle the rest!
describe(ProfilesService.name, () => { jest.setTimeout(60000); let profileService: ProfilesService; beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [HttpModule], controllers: [ProfilesController], providers: [ { provide: ProfilesService, useFactory: (httpService: HttpService) => { return new ProfilesService(httpService, 'http://localhost:4010'); }, inject: [HttpService], }, ], }).compile(); profileService = moduleRef.get(ProfilesService); exec('npm run mock:up'); await new Promise((resolve) => setTimeout(resolve, 5000)); }); afterAll(async () => { exec('npm run mock:down'); }); describe('Get Profile', () => { it('properly integrates with the homes service', async () => { const res = await profileService.getProfile(1); expect(res).not.toBeNull(); expect(res.homes).not.toBeNull(); }); it('properly integrates with the homes service', async () => { const res = await profileService.findProfileHome( 1, 'random', 'street', '12345', ); expect(res.home).not.toBeNull(); expect(res.name).not.toBeNull(); }); }); });
There are a couple of exec calls here, which can be seen in the package.json here
"mock:up": "curl https://raw.githubusercontent.com/Jack-Gitter/contract-testing/refs/heads/main/homes/openapi.json -o homesapi.json && docker compose --profile prism up", "mock:down": "docker compose --profile prism down"mock:up fetches the OpenAPI spec from the homes service first, making sure that the spec is as up to date as possible. It then starts the prism mock server with that newly fetched file. mock:down simply tears down the mock server. With this approach, No mocking of the http service within NestJS is required. The tests can contain real HTTP requests, ensuring compatibility with the homes service contract. Most importantly, it occurs without having to worry about having a real instance spun up, with its dependencies running
Here is the repo link