Improving the Reliability of a NestJS App ─ Logging, Error Handling, and Testing Strategy

  • postgresql
    postgresql
  • prisma
    prisma
  • nestjs
    nestjs
  • docker
    docker
Published on 2024/09/11

Introduction

Up to this point, we’ve put together the basic structure of an article-posting API using NestJS and Prisma.
We’ve confirmed that it works as an API, but when you think about actual production operations, there are many situations where “it just runs” is not enough.

For example:

  • When an error occurs in production, can you quickly identify the cause?
  • When a user reports “something’s wrong,” can you investigate using logs as clues?
  • When data in an unexpected format is sent, can you reject it appropriately?
  • When adding new features, can you verify that existing behavior hasn’t been affected?

To reduce these uncertainties and improve the quality of your API so it can be trusted, you need to set up mechanisms such as error handling, logging, and testing.

In this article, we’ll work on improving application reliability from the following four perspectives:

  • Organizing logs: Use NestJS’s Logger to standardize log content and format
  • Centralizing exception handling: Introduce an ExceptionFilter to unify error handling
  • Introducing tests: Create unit tests for the Service layer and E2E tests that include the DB
  • Separating configuration files: Prepare a .env.test for tests so it can be operated separately from the development environment

When you look toward production operations, code is expected not only to “run” but also to be “code you can safely rely on.”
As a first step toward that goal, we’ll build mechanisms to evolve our API into a more reliable one.

Overall structure of this series and where this article fits

Series structure

  1. Getting Started with Web App Development in NestJS ─ Project Structure and Configuration Management While Building a Blog Site
  2. Let’s Build an Article Posting API with NestJS ─ Basics of Introducing Prisma and Implementing CRUD
  3. Improving Application Reliability ─ Logging, Error Handling, and Testing Strategy ← This article
  4. Deep Dive into Prisma and DB Design ─ Models, Relations, and Operational Design
  5. Build the UI with React and Deploy to Production ─ Docker and Environment Setup

https://shinagawa-web.com/en/blogs/nestjs-blog-series-setup-and-config

https://shinagawa-web.com/en/blogs/nestjs-blog-series-crud-and-prisma-intro

https://shinagawa-web.com/en/blogs/nestjs-blog-series-prisma-db-design

https://shinagawa-web.com/en/blogs/nestjs-blog-series-react-deploy

The code implemented in this article is stored in the following repository, so please refer to it together with this article.

https://github.com/shinagawa-web/nestjs-blog

Using NestJS Logger and Output Format

To understand what’s happening while your application is running, well-organized logs are essential.
NestJS provides a built-in Logger class, which allows unified logging across the entire app without any special setup.

Here, we’ll use this Logger class to review the content and format of our logs.

Basic usage

You can import and use NestJS’s Logger in any class or function.

import { Logger } from '@nestjs/common';

const logger = new Logger('MyContext');

logger.log('Normal log');
logger.warn('Warning log');
logger.error('Error log', error.stack);

The first argument is the message, and the second argument can be error details (e.g., stack trace).
By giving each class a context name like new Logger('ClassName'), it becomes easier to identify logs when they’re output.

Example output at runtime

As shown below, log level, timestamp, and context are included by default, so you get a minimum level of readability without any configuration.

[Nest] 43018   - 2025/04/23 18:30:25   LOG [MyContext] Normal log
[Nest] 43018   - 2025/04/23 18:30:25  WARN [MyContext] Warning log
[Nest] 43018   - 2025/04/23 18:30:25 ERROR [MyContext] Error log

Logging across the entire app

You can use Logger in services, controllers, and so on in the same way.
Here, we’ll configure it to output a log when the article list API is called.

backend/src/posts/posts.service.ts
+ import { Logger } from '@nestjs/common';

@Injectable()
export class PostsService {
+ private readonly logger = new Logger(PostsService.name);

  findAll() {
+   this.logger.log('Retrieving article list');
    return this.prisma.post.findMany();
  }
}
  • By using PostsService.name, the class name is displayed as the log context as-is, which makes management easier.

If you access the API using the method introduced in Part 2, you can confirm that logs are being output on the server side.

Image from Gyazo

Extending log output (introducing a custom Logger)

NestJS’s Logger is convenient as-is, but if you’re thinking about operations, you’ll eventually want to control log destinations, such as “write logs to a file” or “send logs to external services (e.g., Datadog, Sentry).”

That’s where implementing a custom Logger comes in.

Why do we need a custom Logger?

What you want to do Possible with default Logger? Possible with custom Logger
Output logs to terminal ✅ Possible ✅ Possible
Save logs to a file ❌ Not possible ✅ Possible
Filter and save only specific logs ❌ Not possible ✅ Possible
Send logs to external services (e.g., Sentry) ❌ Not possible ✅ Possible
Output in structured JSON format ❌ Not possible ✅ Possible

Implementation image

This time we’ll just show the code, but here’s how to output logs to a file using a custom Logger.

1. Create a custom Logger

src/common/logger/custom-logger.service.ts
import { LoggerService, LogLevel } from '@nestjs/common';
import * as fs from 'fs';

export class CustomLogger implements LoggerService {
  log(message: string) {
    this.writeToFile('LOG', message);
  }

  error(message: string, trace?: string) {
    this.writeToFile('ERROR', `${message} \n ${trace}`);
  }

  warn(message: string) {
    this.writeToFile('WARN', message);
  }

  private writeToFile(level: LogLevel, message: string) {
    const log = `[${new Date().toISOString()}] [${level}] ${message}\n`;
    fs.appendFileSync('app.log', log);
  }
}

In this example, all logs are appended to app.log.

2. Apply it to the application

main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CustomLogger } from './common/logger/custom-logger.service';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    logger: new CustomLogger(),
  });

  await app.listen(3000);
}
bootstrap();

With this configuration, calls like Logger.log() are all processed through CustomLogger.

Advanced examples

  • Write each log level to a separate file
  • Send only errors to Sentry
  • Output in structured JSON format (making it easier to integrate with Fluentd, etc.)
  • Switch log output between CLI, production, and test environments

Benefits of organizing logs

  • You can trace what happened later
  • Reproduction steps for bug reports become clearer
  • Abnormal cases become visible as logs and easier to detect

In the early stages of development, console.log() may be enough, but by organizing logs you gain a “visible sense of safety.”

Error handling

When developing an API, you can’t avoid situations where unexpected requests or system errors occur.
NestJS provides standard error handling mechanisms such as HttpException, but the format of error responses is not consistent, and logs also lack uniformity.

So this time, we’ll use an ExceptionFilter to unify how exceptions are handled and how they’re output.

What is an ExceptionFilter?

An ExceptionFilter is a mechanism provided by NestJS to control exception handling cross-cuttingly.
It can catch specific exceptions (or all exceptions) and centralize log output and response formatting.

Creating a custom filter

First, create an exception filter class.

npx nest g filter common/filters/http

You should see a file like the one below generated.
This is a class definition for intercepting (catching) exceptions that occur in NestJS and handling them yourself.
In NestJS, you can define logic to handle global or specific exceptions using the ExceptionFilter mechanism.

src/common/filters/http.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';

@Catch()
export class HttpFilter<T> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {}
}
  • @Catch() decorator
    • No arguments: targets all exceptions (including Error)
  • catch() method
    • Automatically called when an exception occurs
    • exception: T: the actual exception that occurred (HttpException, Error, ValidationError, etc.)
    • host: ArgumentsHost: execution context (you can access request, response, GraphQL context, etc.)

This code is essentially “scaffolding for writing your own exception handling,” and we’ll now add response formatting and log output on top of it.

Here is the code with log output and response formatting added.

backend/src/common/filters/http.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(HttpExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    let message = 'Internal server error';

    if (exception instanceof HttpException) {
      const res = exception.getResponse();

      if (
        typeof res === 'object' &&
        res !== null &&
        'message' in res &&
        typeof (res as Record<string, unknown>).message === 'string'
      ) {
        message = (res as Record<string, unknown>).message as string;
      }
    }

    this.logger.error(
      `[${request.method}] ${request.url}`,
      JSON.stringify(message),
    );

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message,
    });
  }
}

Here’s some explanation of the contents of the catch() method.

const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
  • Extracting request/response information
    • Use switchToHttp() to narrow down to HTTP (Express)
    • Use getResponse() / getRequest() to get each object
const status =
  exception instanceof HttpException
    ? exception.getStatus()
    : HttpStatus.INTERNAL_SERVER_ERROR;
  • Determining the status code
    • If it’s an HttpException, the exception itself has a status code
    • Otherwise (for unexpected exceptions), return 500 (INTERNAL_SERVER_ERROR)
let message = 'Internal server error';

if (exception instanceof HttpException) {
  const res = exception.getResponse();

  if (
    typeof res === 'object' &&
    res !== null &&
    'message' in res &&
    typeof (res as Record<string, unknown>).message === 'string'
  ) {
    message = (res as Record<string, unknown>).message as string;
  }
}
  • Getting the message
    • For HttpException, getResponse() returns human-readable content (or a list of DTO validation errors)
    • For other exceptions, we use the exception as-is (for logging)
this.logger.error(
  `[${request.method}] ${request.url}`,
  JSON.stringify(message),
);
  • Log output
    • Here we output “HTTP method + path” and “message”
    • Using JSON.stringify ensures that even if the message is an array or object, it can be safely logged
response.status(status).json({
  statusCode: status,
  timestamp: new Date().toISOString(),
  path: request.url,
  message,
});
  • Response to the client
    • Specify the status code and return a JSON response in a common format
    • This allows the frontend to handle errors consistently, e.g., “when an error comes, just look at message

Global application settings

To apply the filter you created to the entire app, call app.useGlobalFilters() in main.ts.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
+ import { HttpExceptionFilter } from './common/filters/http/http.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
+ app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap().catch((err) => {
  console.error('Application failed to start', err);
});

If you call the create-post API with an empty title, you’ll get a response like this:

Image from Gyazo

You can confirm that the status code, timestamp, message, etc. are correctly set by the custom filter we created earlier.

Image from Gyazo

You can also confirm that logs are being output on the server side.

Image from Gyazo

The message is an array, allowing multiple messages to be stored. On the frontend, you can use this message to display errors on the screen.

Image from Gyazo

You can also confirm that logs are being output on the server side.

Unit tests for the Service layer

To verify that the internal logic of the application behaves correctly according to the specification, we decided to set up unit tests.
Unit tests focus on the behavior of individual functions and verify whether those functions produce the correct output or processing for given inputs.

In particular, the Service layer is where important logic close to business rules is concentrated, such as “how to query the database” and “what data structure to return for a request.”
By carefully testing this layer, you gain confidence when adding features or refactoring.

Characteristics of unit tests

In unit tests, external dependencies (DBs, APIs, etc.) are mocked (replaced with fake behavior) to verify the behavior of the target function.
This allows you to focus on the function itself without having to actually start a database or worry about the data contents.

This time, we’ll target PostsService, which handles article posting, and implement unit tests for basic methods (create, get all, get by ID).
We’ll mock PrismaService and verify that each method calls the appropriate Prisma Client methods as expected.

Methods under test

Below are the methods implemented in the Service layer.
We’ll write tests to verify their behavior.

backend/src/posts/posts.service.ts
import { Injectable } from '@nestjs/common';
import { CreatePostDto } from './dto/create-post.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { PrismaService } from 'src/prisma/prisma.service';
import { Logger } from '@nestjs/common';

@Injectable()
export class PostsService {
  constructor(private readonly prisma: PrismaService) {}
  private readonly logger = new Logger(PostsService.name);

  create(createPostDto: CreatePostDto) {
    return this.prisma.post.create({ data: createPostDto });
  }

  findAll() {
    this.logger.log('Fetching article list');
    return this.prisma.post.findMany();
  }

  findOne(id: number) {
    return this.prisma.post.findUnique({ where: { id } });
  }

  update(id: number, updatePostDto: UpdatePostDto) {
    return this.prisma.post.update({
      where: { id },
      data: updatePostDto,
    });
  }

  remove(id: number) {
    return this.prisma.post.delete({ where: { id } });
  }
}

Prepare a mock for PrismaService

In unit tests, we don’t test Prisma’s own behavior; instead, we verify that the target methods call specific Prisma methods correctly.

  const mockPrismaService = {
    post: {
      create: jest.fn(),
      findMany: jest.fn(),
      findUnique: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
    },
  };
  • We use jest.fn() to create empty functions whose call counts and arguments can be inspected.

Full test code

The final test code looks like this:

src/posts/posts.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { PostsService } from './posts.service';
import { PrismaService } from '../prisma/prisma.service';
import { Logger } from '@nestjs/common';

describe('PostsService', () => {
  let service: PostsService;

  const mockPrismaService = {
    post: {
      create: jest.fn(),
      findMany: jest.fn(),
      findUnique: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
    },
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        PostsService,
        {
          provide: PrismaService,
          useValue: mockPrismaService,
        },
      ],
    }).compile();

    service = module.get<PostsService>(PostsService);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  it('should call prisma.post.create with correct data', async () => {
    const dto = { title: 'Test', content: 'Content' };
    await service.create(dto);

    expect(mockPrismaService.post.create).toHaveBeenCalledWith({
      data: dto,
    });
  });

  it('should call prisma.post.findMany and log a message', async () => {
    const logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation();

    await service.findAll();

    expect(mockPrismaService.post.findMany).toHaveBeenCalled();
    expect(logSpy).toHaveBeenCalledWith('Fetching article list');

    logSpy.mockRestore();
  });

  it('should call prisma.post.findUnique with correct id', async () => {
    await service.findOne(42);

    expect(mockPrismaService.post.findUnique).toHaveBeenCalledWith({
      where: { id: 42 },
    });
  });

  it('should call prisma.post.update with correct id and data', async () => {
    const dto = { title: 'Updated', content: 'Updated content' };

    await service.update(99, dto);

    expect(mockPrismaService.post.update).toHaveBeenCalledWith({
      where: { id: 99 },
      data: dto,
    });
  });

  it('should call prisma.post.delete with correct id', async () => {
    await service.remove(7);

    expect(mockPrismaService.post.delete).toHaveBeenCalledWith({
      where: { id: 7 },
    });
  });
});

Here’s some explanation of the test code.

let service: PostsService;

const mockPrismaService = {
  post: {
    create: jest.fn(),
    findMany: jest.fn(),
    findUnique: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
  },
};

beforeEach(async () => {
  const module: TestingModule = await Test.createTestingModule({
    providers: [
      PostsService,
      {
        provide: PrismaService,
        useValue: mockPrismaService,
      },
    ],
  }).compile();

  service = module.get<PostsService>(PostsService);
});
  • In beforeEach, we build the test environment each time
    • Use TestingModule to inject PostsService
    • Replace PrismaService with mockPrismaService instead of the real one
    • This ensures that even if this.prisma.post.create() etc. are used inside PostsService, the real DB is never accessed.
  afterEach(() => {
    jest.clearAllMocks();
  });
  • Reset the call history of each jest.fn() after each test
it('should call prisma.post.create with correct data', async () => {
  const dto = { title: 'Test', content: 'Content' };
  await service.create(dto);
  expect(mockPrismaService.post.create).toHaveBeenCalledWith({ data: dto });
});
  • Verifies that the input dto is passed to the create method as-is
it('should call prisma.post.findMany and log a message', async () => {
  const logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation();

  await service.findAll();

  expect(mockPrismaService.post.findMany).toHaveBeenCalled();
  expect(logSpy).toHaveBeenCalledWith('Fetching article list');

  logSpy.mockRestore();
});
  • Confirms that findMany() was called
  • Also checks that the log message “Fetching article list” was actually output
it('should call prisma.post.findUnique with correct id', async () => {
  await service.findOne(42);
  expect(mockPrismaService.post.findUnique).toHaveBeenCalledWith({
    where: { id: 42 },
  });
});
  • Verifies the call to findUnique() with an ID argument
it('should call prisma.post.update with correct id and data', async () => {
  const dto = { title: 'Updated', content: 'Updated content' };
  await service.update(99, dto);
  expect(mockPrismaService.post.update).toHaveBeenCalledWith({
    where: { id: 99 },
    data: dto,
  });
});
  • Checks that the where condition and update data are passed correctly
it('should call prisma.post.delete with correct id', async () => {
  await service.remove(7);
  expect(mockPrismaService.post.delete).toHaveBeenCalledWith({
    where: { id: 7 },
  });
});
  • Verifies that delete is called with the specified ID

Jest configuration

Next, we’ll configure Jest so it can resolve paths.

package.json
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "src",
    "testRegex": ".*\\.spec\\.ts$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node",
+   "moduleNameMapper": {
+     "^src/(.*)$": "<rootDir>/$1"
+   }
  }
tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2023",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": true,
    "forceConsistentCasingInFileNames": true,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "noFallthroughCasesInSwitch": false,
+   "resolveJsonModule": true,
+   "paths": {
+     "src/*": ["src/*"]
+   }
  }
}

Running unit tests with Jest

Now that the configuration is complete, let’s run the unit tests.

cd backend
npm run test -- src/posts/posts.service.spec.ts

You can confirm that the tests finished successfully.

Image from Gyazo

E2E tests through the Controller

Following the unit tests for the Service layer, we’ll now introduce E2E (End-to-End) tests to verify the behavior of the entire application.
In E2E tests, we actually start the NestJS application, send HTTP requests, and verify the responses.

Because a single request covers everything from the controller to the service and DB access via Prisma, you can perform checks that are close to real production behavior, which is a major advantage.

E2E test setup and tools

  • @nestjs/testing: Official Nest utility for starting the entire application in test mode
  • supertest: Library for sending requests to the running HTTP server and verifying responses

Prepare a DB for tests

E2E tests perform CRUD operations against a real database.
For this reason, we’ll prepare a “test-only DB” that is completely separate from the development DB.

Add test-db to docker-compose

docker-compose.yml
services:
  db:
    image: postgres:15
    container_name: nestjs-postgres
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: nestjs_blog
    volumes:
      - db-data:/var/lib/postgresql/data

+ test-db:
+   image: postgres:15
+   container_name: nestjs-postgres-test
+   ports:
+     - "5433:5432"
+   environment:
+     POSTGRES_USER: postgres
+     POSTGRES_PASSWORD: postgres
+     POSTGRES_DB: test_db
+   volumes:
+     - test-db-data:/var/lib/postgresql/data

volumes:
  db-data:
+ test-db-data:

Define DB connection info in .env.test.local

.env.test.local
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/test_db

Configure Jest to load DB connection info

We’ll prepare new code to load .env.test.local and then explicitly overwrite environment variables with the loaded values.

backend/src/config/dotenv/unit-test-config.ts
import * as dotenv from 'dotenv';
import * as path from 'path';

const testEnv = dotenv.config({
  path: path.join(process.cwd(), '.env.test.local'),
});

Object.assign(process.env, {
  ...testEnv.parsed,
});

We’ll have Jest run this file before executing tests.

backend/test/jest-e2e.json
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": "../",
  "moduleNameMapper": {
    "^src/(.*)$": "<rootDir>/src/$1"
  },
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  },
+ "setupFiles": [
+   "<rootDir>/src/config/dotenv/unit-test-config.ts"
+ ]
}

DB initialization

In E2E tests, ideally you want to:

  • Create the schema at test startup
  • Insert only the necessary initial data
  • Keep the state clean for each test

Here, we’ll reset the data at test startup.

test/utils/db.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export async function resetDatabase() {
  await prisma.post.deleteMany();
}

We delete all records from the target Post table to remove data created by previous tests.

Implementing the test code

backend/test/posts.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { resetDatabase } from './utils/db';

describe('PostsController (e2e)', () => {
  let app: INestApplication;
  let createdPostId: number;
  beforeAll(async () => {
    await resetDatabase();
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  it('/posts (POST)', async () => {
    const response = await request(app.getHttpServer())
      .post('/posts')
      .send({ title: 'E2E Test Title', content: 'E2E Test Content' })
      .expect(201);

    expect(response.body).toHaveProperty('id');
    expect(response.body.title).toBe('E2E Test Title');
    createdPostId = response.body.id;
  });

  it('/posts (GET)', async () => {
    const response = await request(app.getHttpServer())
      .get('/posts')
      .expect(200);
    expect(Array.isArray(response.body)).toBe(true);
    expect(response.body.length).toBeGreaterThan(0);
  });

  it('GET /posts/:id', async () => {
    const response = await request(app.getHttpServer())
      .get(`/posts/${createdPostId}`)
      .expect(200);

    expect(response.body.id).toBe(createdPostId);
    expect(response.body.title).toBe('E2E Test Title');
  });

  it('PATCH /posts/:id', async () => {
    const response = await request(app.getHttpServer())
      .patch(`/posts/${createdPostId}`)
      .send({ title: 'Updated Title', content: 'Updated Content' })
      .expect(200);

    expect(response.body.title).toBe('Updated Title');
    expect(response.body.content).toBe('Updated Content');
  });

  it('DELETE /posts/:id', async () => {
    await request(app.getHttpServer())
      .delete(`/posts/${createdPostId}`)
      .expect(200);

    await request(app.getHttpServer())
      .get(`/posts/${createdPostId}`)
      .expect(404);
  });
});

Here’s an explanation of the code.

let app: INestApplication;
let createdPostId: number;
  • app: holds the NestJS application under test
  • createdPostId: a variable to reuse the ID of the post created by POST /posts in subsequent tests
beforeAll(async () => {
  await resetDatabase();
  const moduleFixture = await Test.createTestingModule({ imports: [AppModule] }).compile();
  app = moduleFixture.createNestApplication();
  await app.init();
});
  • Initialization before tests
    • resetDatabase(): cleans the DB state before tests (removes previous posts)
    • Starting the NestJS application:
      • Prepare the module with Test.createTestingModule(...)
      • Actually start the app with app.init()
afterAll(async () => {
  await app.close();
});
  • Shut down the app after tests to free the port
  • Important to prevent memory leaks and port conflicts
it('/posts (POST)', async () => {
  const response = await request(app.getHttpServer())
    .post('/posts')
    .send({ title: 'E2E Test Title', content: 'E2E Test Content' })
    .expect(201);

  expect(response.body).toHaveProperty('id');
  expect(response.body.title).toBe('E2E Test Title');
  createdPostId = response.body.id;
});
  • POST /posts: create a post
    • Verifies that a post can be created correctly
    • Saves the returned id in createdPostId for reuse in later tests
it('/posts (GET)', async () => {
  const response = await request(app.getHttpServer())
    .get('/posts')
    .expect(200);
  expect(Array.isArray(response.body)).toBe(true);
  expect(response.body.length).toBeGreaterThan(0);
});
  • GET /posts: get list
    • Verifies that the list can be retrieved
    • Checks that the response is an array and that there is at least one item
it('GET /posts/:id', async () => {
  const response = await request(app.getHttpServer())
    .get(`/posts/${createdPostId}`)
    .expect(200);

  expect(response.body.id).toBe(createdPostId);
  expect(response.body.title).toBe('E2E Test Title');
});
  • GET /posts/:id: get single post
    • Verifies that the post created earlier can be retrieved
    • Checks that the ID and title match
it('PATCH /posts/:id', async () => {
  const response = await request(app.getHttpServer())
    .patch(`/posts/${createdPostId}`)
    .send({ title: 'Updated Title', content: 'Updated Content' })
    .expect(200);

  expect(response.body.title).toBe('Updated Title');
  expect(response.body.content).toBe('Updated Content');
});
  • PATCH /posts/:id: update
    • Verifies that both title and content are updated
it('DELETE /posts/:id', async () => {
  await request(app.getHttpServer())
    .delete(`/posts/${createdPostId}`)
    .expect(200);

  await request(app.getHttpServer())
    .get(`/posts/${createdPostId}`)
    .expect(404);
});
  • DELETE /posts/:id: delete → 404 on re-fetch
    • Verifies that deletion succeeded
    • Then calls GET /posts/:id and expects 404 Not Found to confirm that the post is indeed deleted

Verifying behavior

Run the E2E tests in the following order.

Start the test DB

docker-compose up -d test-db

Apply migrations

DATABASE_URL=postgresql://postgres:postgres@localhost:5433/test_db npx prisma migrate deploy

Run E2E tests

npm run test:e2e

When we ran the tests, one of them failed.

Image from Gyazo

In the delete API test, we expected 404 on re-fetch after deletion, but got 200.

The current code is as follows:

backend/src/posts/posts.service.ts
  findOne(id: number) {
    return this.prisma.post.findUnique({ where: { id } });
  }

In this case, even if null is returned, NestJS will still return 200.
We’ll fix it so that 404 is returned when the specified ID does not exist.

backend/src/posts/posts.service.ts
import { NotFoundException } from '@nestjs/common';

async findOne(id: number) {
  const post = await this.prisma.post.findUnique({ where: { id } });
  if (!post) throw new NotFoundException(`Post with ID ${id} not found`);
  return post;
}

After fixing the service and running the E2E tests again, all of them pass successfully.

Image from Gyazo

backend/test/posts.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { resetDatabase } from './utils/db';
import { Server } from 'http';

type PostResponse = {
  id: number;
  title: string;
  content: string;
};

describe('PostsController (e2e)', () => {
  let app: INestApplication;
  let httpServer: Server;
  let createdPostId: number;
  beforeAll(async () => {
    await resetDatabase();
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
    httpServer = app.getHttpServer() as Server;
  });

  afterAll(async () => {
    await app.close();
  });

  it('/posts (POST)', async () => {
    const response = await request(httpServer)
      .post('/posts')
      .send({ title: 'E2E Test Title', content: 'E2E Test Content' })
      .expect(201);

    const body = response.body as PostResponse;
    expect(body).toHaveProperty('id');
    expect(body.title).toBe('E2E Test Title');
    createdPostId = body.id;
  });

  it('/posts (GET)', async () => {
    const response = await request(httpServer).get('/posts').expect(200);
    const body = response.body as PostResponse[];
    expect(Array.isArray(body)).toBe(true);
    expect(body.length).toBeGreaterThan(0);
  });

  it('GET /posts/:id', async () => {
    const response = await request(httpServer)
      .get(`/posts/${createdPostId}`)
      .expect(200);
    const body = response.body as PostResponse;

    expect(body.id).toBe(createdPostId);
    expect(body.title).toBe('E2E Test Title');
  });

  it('PATCH /posts/:id', async () => {
    const response = await request(httpServer)
      .patch(`/posts/${createdPostId}`)
      .send({ title: 'Updated Title', content: 'Updated Content' })
      .expect(200);
    const body = response.body as PostResponse;

    expect(body.title).toBe('Updated Title');
    expect(body.content).toBe('Updated Content');
  });

  it('DELETE /posts/:id', async () => {
    await request(httpServer).delete(`/posts/${createdPostId}`).expect(200);

    await request(httpServer).get(`/posts/${createdPostId}`).expect(404);
  });
});

Conclusion

In this article, as a foundation for improving the reliability of a NestJS application, we have gone through:

  • Organizing log output
  • Centralized error handling
  • Implementing unit tests and E2E tests targeting the Service and Controller layers

In particular, by making E2E tests reproducible for the entire flow including the DB, and verifying behavior in a way close to real usage scenarios, we’ve increased the reliability of the API.

Of course, there is still plenty of room for improvement and extension: switching log destinations to files or external services, broadening the coverage of the ExceptionFilter, making test data initialization strategies more flexible, and so on. I hope to cover those in future steps.

Next article:

https://shinagawa-web.com/en/blogs/nestjs-blog-series-prisma-db-design

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form