4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

バックエンド API においてのユニットテスト

Last updated at Posted at 2025-01-30

はじめに

記事の目的と背景

私が学習を始めた当初、実践的な自動テストの情報が少なく、書籍や記事では公式の簡単なサンプルの領域を超えないものばかりが紹介されていました。実務に即した内容や、API のユニットテストを具体的に扱った資料がほとんどなかったため、試行錯誤を重ねる必要がありました。
現在では、ユニットテストに関する情報が増えてきたものの、これから学び始める方や現場で適用する方にとって、実践的なノウハウを学べる機会はまだ多くありません。私の学習体験が、少しでもどなたかの参考になればと思い、この知見を記事としてまとめました。

対象読者

本記事は、以下の方々を対象にしています。

  • これからユニットテストの基本を学びたいと考えている初心者
  • 現場でユニットテストを実践し、より効果的な方法を模索している中級者

ユニットテストの基本

ユニットテストとは?

ユニットテストとは、コードの最小機能ユニットをテストするプロセスです。
ソフトウェアテストはコードの品質を保証するのに役立ち、ソフトウェア開発に欠かせないものです。
ソフトウェアを小さく機能的なユニットとして作成し、コードユニットごとにユニットテストを作成するのがソフトウェア開発のベストプラクティスです。
まず、ユニットテストをコードとして記述できます。
次に、ソフトウェアコードを変更するたびに、そのテストコードを自動的に実行します。
これにより、テストが失敗した場合でも、バグやエラーのあるコードの領域をすばやく特定できます。
ユニットテストはモジュラー思考のパラダイムを強化し、テストの範囲と品質を向上させます。 [1]

ユニットテストのメリット

ユニットテストを実装することにより、以下のメリットが得られます。

  • 早期にバグを発見することができる: テスト対象を小さい単位で分けることができるため、バグの発生箇所の特定が容易
  • 改修による影響範囲を限定できる: 改修後も既存動作が保証されることをテストで確認可能
  • 再現性が高い: 同じテストコードを何度も実行できるため安定してテストが可能

手動テストとの違い

No 項目 ユニットテスト 手動テスト
1 実行速度 高速、自動化可能 人力で操作するため時間がかかる
2 再現性 高い 再現が低い(操作ミスや人により解釈ミスの可能性がある)
3 工数 初回のみ多くの工数が発生 一定の工数が予測できるが、短縮は難しい
4 主な用途 コード単位の動作確認 E2E や UI テスト

基本的には、テストコードを記述することで、短時間で多くのテストを実行でき、再現性が向上し、結果としてソフトウェアの品質向上につながります。
ただし、初期工数は手動テストより高くなります。また、コードを適切に分離していないとテストコードが複雑になり形骸化する恐れがあります。

本記事で使用する開発環境とツール

  • Node.js: 22.12.0
  • npm: 10.9.0
  • NestJS: 10.0.0
  • TypeScript: 5.1.3
  • Jest: 29.5.0
  • TypeORM: 0.3.20

テスト対象のバックエンド API

本セクションでは、ユニットテストの対象となるバックエンド API の構造と機能を紹介します。
対象コードの全体像を把握し、後のテスト設計を理解しやすくすることを目的としています。

サービス層とコントローラー層の概要

バックエンド API は、リクエストを処理し、適切なレスポンスを返す「コントローラー層」と、データベースとのやり取りを管理する「サービス層」の2つの主要な部分から構成されています。

  • サービス層: ビジネスロジックとデータアクセスを担当します
  • コントローラー層: エンドポイントを提供し、リクエストを処理します

これらの層がどのように連携して動作するかを以下のコード例で見てみましょう。

実装例(サービスクラス・コントローラークラスのコード)

以下は、サービス層(users.service.ts)とコントローラー層(users.controller.ts)のサンプルコードです。このコードは、後のテストケースで使用する基盤となります。

users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @Inject('USERS_REPOSITORY') private usersRepository: Repository<Users>,
  ) {}

  async findAll(): Promise<Users[]> {
    return this.usersRepository.find({
      select: {
        id: true,
        name: true,
        email: true,
        birthday: true,
        createdAt: true,
      },
    })
  }

  async findOneWithQuery(query: Partial<FindUsersDto>): Promise<Users[]> {
    const result = await this.usersRepository.findOne({
      select: {
        id: true,
        name: true,
        email: true,
        birthday: true,
        createdAt: true,
      },
      where: { ...query },
    })
    return result ? [result] : null
  }

  async update(id: string, updateUsersDto: UpdateUsersDto): Promise<number> {
    const result = await this.usersRepository.update(id, { ...updateUsersDto })
    return result?.affected ?? 0
  }

  // 以下省略
}
users.controller.ts
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  async find(@Query('email') email: string) {
    const users = email
      ? await this.usersService.findOneWithQuery({ email })
      : await this.usersService.findAll()

    if (!users) {
      throw new HttpException('User not found', HttpStatus.NOT_FOUND)
    }

    return {
      message: 'User successfully found',
      status: HttpStatus.OK,
      body: users,
    }
  }

  // 以下省略
}

サービスクラスのテスト

今回紹介するサービスクラスのユニットテストコード(抜粋)

users.service.spec.ts
type SpyInstanceType = {
  find: jest.SpyInstance
  findOne: jest.SpyInstance
  update: jest.SpyInstance
}

describe('UsersService', () => {
  let service: UsersService
  const mockUsers = [
    {
      id: '00000000-0000-0000-0000-000000000000',
      name: 'test',
      email: 'test@test.com',
      birthday: new Date('2024-12-19T12:53:40.434Z'),
      createdAt: new Date('2024-12-20T12:54:05.631Z'),
    },
    {
      id: '00000000-0000-0000-0000-000000000001',
      name: 'sample',
      email: 'sample@test.com',
      birthday: new Date('2024-12-19T12:53:40.434Z'),
      createdAt: new Date('2024-12-20T12:54:05.631Z'),
    },
  ] as Users[]
  const spyInstance: SpyInstanceType = {
    find: undefined,
    findOne: undefined,
    update: undefined,
  }

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [UsersService, ...usersProviders],
    }).compile()

    service = module.get<UsersService>(UsersService)

    Object.entries(spyInstance).map(
      (instance) =>
        (spyInstance[instance[0]] = jest.spyOn(
          service['usersRepository'],
          instance[0] as never,
        )),
    )
  })

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

  describe('findAll', () => {
    it('should return a list of users when users exist', async () => {
      spyInstance.find.mockImplementation(async () => mockUsers)

      const result = await service.findAll()
      expect(result).toEqual(mockUsers)
    })

    it('should return null when no users are found', async () => {
      spyInstance.find.mockImplementation(async () => null)

      const result = await service.findAll()
      expect(result).toBeNull()
    })
  })

  describe('findOneWithQuery', () => {
    it('should find a user by ID when the ID exists', async () => {
      spyInstance.findOne.mockImplementation(async ({ where }) =>
        mockUsers.find((item) => item.id === where.id),
      )

      const result = await service.findOneWithQuery({ id: mockUsers[0].id })
      expect(result).toEqual([mockUsers[0]])
    })

    it('should return null when no matching user is found', async () => {
      spyInstance.find.mockImplementation(async () => null)

      const result = await service.findOneWithQuery(null)
      expect(result).toBeNull()
    })
  })

  describe('update', () => {
    it('should update a user successfully when valid data is provided', async () => {
      spyInstance.update.mockImplementation(async () => {
        return {
          raw: [],
          affected: 1,
          generatedMaps: [],
        }
      })

      const result = await service.update('', {})
      expect(result).toBe(1)
    })

    it('should return 0 when user update fails', async () => {
      spyInstance.update.mockImplementation(async () => null)

      const result = await service.update('', {})
      expect(result).toBe(0)
    })
  })
})

サービスクラスの findAll のユニットテスト

テスト対象

async findAll(): Promise<Users[]> {
  return this.usersRepository.find({
    select: {
      id: true,
      name: true,
      email: true,
      birthday: true,
      createdAt: true,
    },
  })
}

findAll ではシンプルにユーザーリポジトリの find を返しています。
まず、ユーザーリポジトリの find が何を返すかを知る必要があります。
find は TypeORM の find メソッドになり、返り値は指定した Entity の配列か null になります。
なので findAll のユニットテストは Entity の配列( Users[] )か null が結果として返ることを確認する必要があります。

describe('findAll', () => {
  it('should return a list of users when users exist', async () => {
    // ユーザーが存在する場合をシミュレート
    spyInstance.find.mockImplementation(async () => mockUsers)

    const result = await service.findAll()
    // モックデータと期待される結果の比較
    expect(result).toEqual(mockUsers)
  })

  it('should return null when no users are found', async () => {
    // ユーザーが存在しない場合をシミュレート
    spyInstance.find.mockImplementation(async () => null)
    const result = await service.findAll()
    // 結果が null であることを確認
    expect(result).toBeNull()
  })
})

findAll テスト説明

should return a list of users when users exist

一つ目のテストではデータが有った場合のユニットテストです。
そのまま findAll を実行してしまうと DB に接続してデータを取得してきてしまいます。

ユニットテストでは値の整合性はチェックをしません。
なので、 findAll の中身を想定される値が変えるように予め変えておきます。
service.findAll() を実行された場合 mockUsers が変えるように置き換えます。

const result = await service.findAll()
此処のコードで置き換えた findAll を呼び出し値をもらいます。

expect(result).toEqual(mockUsers)
最後に想定された値が返ってきているかチェックをします。

should return null when no users are found

二番目のテストではデータが無かった場合の処理のユニットテストです。
一つ目と同様に想定される値が変えるようにメソッドを置き換えます。
データがなかった場合は null が返るので呼び出されたときに null を返します。

同様に値をもらって null であることをチェックします。

サービスクラスの findOneWithQuery のユニットテスト

テスト対象

export class FindUsersDto {
  @IsString()
  id: string

  @IsString()
  email: string
}

async findOneWithQuery(query: Partial<FindUsersDto>): Promise<Users[]> {
  const result = await this.usersRepository.findOne({
    select: {
      id: true,
      name: true,
      email: true,
      birthday: true,
      createdAt: true,
    },
    where: { ...query },
  })
  return result ? [result] : null
}

findOneWithQuery では id もしくは email でユーザーリポジトリの findOne で一致するユーザーを返しています。
findAll と同じように考えます。
条件に一致するユーザーがあれば findOne は指定した Entity か null が返ります。
今回は return でユーザー情報が取れれば配列にして返し、無ければ null を返すようにしています。

describe('findOneWithQuery', () => {
  it('should find a user by ID when the ID exists', async () => {
    // IDが存在する場合はIDでユーザーを検索するシミュレート
    spyInstance.findOne.mockImplementation(async ({ where }) =>
      mockUsers.find((item) => item.id === where.id),
    )
    const result = await service.findOneWithQuery({ id: mockUsers[0].id })
    expect(result).toEqual([mockUsers[0]])
  })
  it('should return null when no matching user is found', async () => {
    // ユーザーが存在しない場合をシミュレート
    spyInstance.findOne.mockImplementation(async () => null)
    const result = await service.findOneWithQuery(null)
    expect(result).toBeNull()
  })
})

findOneWithQuery テスト説明

should find a user by ID when the ID exists

一つ目のテストでは一致するデータが有った場合のユニットテストです。
findAll でテストした時と同じようにユーザーリポジトリのメソッドを想定されるデータに置き換えます。
findOne はオブジェクト一件が返るので想定データも一件になります。

findOneWithQuery はオブジェクトを配列にして返しているので返り値が配列であることをチェックしています。

should return null when no matching user is found

二つ目のテストではユーザー情報が取れなかった場合のテストです。
内容的には findAll のデータが無かった場合のテストと同じになります。

サービスクラスの update のユニットテスト

テスト対象

export class CreateUsersDto {
  @IsString()
  name: string

  @IsString()
  email: string

  @IsString()
  password: string

  @IsDate()
  birthday: Date
}

export class UpdateUsersDto extends PartialType(CreateUsersDto) {}

async update(id: string, updateUsersDto: UpdateUsersDto): Promise<number> {
  const result = await this.usersRepository.update(id, { ...updateUsersDto })
  return result?.affected ?? 0
}

update では id に一致するユーザーのデータを更新します。
今回は更新ができれば UpdateResult が返ってきます。
更新ができなければ null が返ってきます。

return で更新件数を返しています。
更新できなければ0を固定で返しています。

describe('update', () => {
  it('should update a user successfully when valid data is provided', async () => {
    // 更新できた場合をシミュレート
    spyInstance.update.mockImplementation(async () => {
      return {
        raw: [],
        affected: 1,
        generatedMaps: [],
      }
    })
    const result = await service.update('', {})
    // 結果が 1 であることを確認
    expect(result).toBe(1)
  })
  it('should return 0 when user update fails', async () => {
    // 更新ができなかった場合をシミュレート
    spyInstance.update.mockImplementation(async () => null)
    const result = await service.update('', {})
    // 結果が 0 であることを確認
    expect(result).toBe(0)
  })
})

update テスト説明

should update a user successfully when valid data is provided

一つ目のテストでは更新ができた場合のユニットテストです。
更新できた場合の UpdateResult と同じオブジェクトを返すようにユーザーリポジトリの update を置き換えます。

1件更新できたことを想定してチェックします。

should return 0 when user update fails

二つ目のテストでは更新ができなかった場合のユニットテストです。
更新できなかった場合は null が返ってきますので null で置き換えます。

更新ができなかった場合は0が固定で返されますので0でチェックします。

コントローラークラスのテスト

今回紹介するコントローラークラスのユニットテストコード(抜粋)

users.controller.spec.ts
type SpyInstanceType = {
  findAll: jest.SpyInstance
  findOneWithQuery: jest.SpyInstance
}

describe('UsersController', () => {
  let controller: UsersController
  const mockUsers = [
    {
      id: '00000000-0000-0000-0000-000000000000',
      name: 'test',
      email: 'test@test.com',
      birthday: new Date('2024-12-19T12:53:40.434Z'),
      createdAt: new Date('2024-12-20T12:54:05.631Z'),
    },
  ] as Users[]
  const spyInstance: SpyInstanceType = {
    findOneWithQuery: undefined,
    findAll: undefined,
  }

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [UsersService, ...usersProviders],
    }).compile()

    controller = module.get<UsersController>(UsersController)

    Object.entries(spyInstance).map(
      (instance) =>
        (spyInstance[instance[0]] = jest.spyOn(
          controller['usersService'],
          instance[0] as never,
        )),
    )
  })

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

  describe('find', () => {
    it('should call the appropriate service method based on the input', async () => {
      spyInstance.findOneWithQuery.mockImplementation(async () => [])
      spyInstance.findAll.mockImplementation(async () => [])

      expect(spyInstance.findOneWithQuery).toHaveBeenCalledTimes(0)
      expect(spyInstance.findAll).toHaveBeenCalledTimes(0)
      await controller.find('test')
      expect(spyInstance.findOneWithQuery).toHaveBeenCalledTimes(1)
      expect(spyInstance.findAll).toHaveBeenCalledTimes(0)
      await controller.find(undefined)
      expect(spyInstance.findOneWithQuery).toHaveBeenCalledTimes(1)
      expect(spyInstance.findAll).toHaveBeenCalledTimes(1)
    })

    it('should throw an error if the user is not found', async () => {
      spyInstance.findOneWithQuery.mockImplementation(async () => null)
      spyInstance.findAll.mockImplementation(async () => null)

      await expect(controller.find('test')).rejects.toThrow(
        new HttpException('User not found', HttpStatus.NOT_FOUND),
      )
      await expect(controller.find(undefined)).rejects.toThrow(
        new HttpException('User not found', HttpStatus.NOT_FOUND),
      )
    })

    it('should return a success message and the user data if the user is found', async () => {
      spyInstance.findOneWithQuery.mockImplementation(async () => mockUsers)
      spyInstance.findAll.mockImplementation(async () => mockUsers)

      const resultFindByEmail = await controller.find('test')
      expect(resultFindByEmail).toEqual({
        message: 'User successfully found',
        status: HttpStatus.OK,
        body: mockUsers,
      })
      const resultFindAll = await controller.find(undefined)
      expect(resultFindAll).toEqual({
        message: 'User successfully found',
        status: HttpStatus.OK,
        body: mockUsers,
      })
    })
  })
})

コントローラークラスの find のユニットテスト

テスト対象

async find(@Query('email') email: string) {
  const users = email
    ? await this.usersService.findOneWithQuery({ email })
    : await this.usersService.findAll()
  if (!users) {
    throw new HttpException('User not found', HttpStatus.NOT_FOUND)
  }
  return {
    message: 'User successfully found',
    status: HttpStatus.OK,
    body: users,
  }
}

find ではクエリパラメーターに email が有る場合を email が一致するユーザーを取得してきます。
クエリパラメータがない場合はすべてのユーザーを取得してきます。
ユーザーが取得できなければエラーを返し、ユーザーが取得できれば200でユーザーを返します。

findOneWithQuery と findAll は前章でテストしており ユーザーオブジェクトの配列か null が返ってくることを担保しています。
なので、以下のことを確認する必要があります。

  • クエリパラメータの有無で処理が変わること
  • null の時404が返ること
  • ユーザーが取得できた場合200でユーザー情報が返ってること
describe('find', () => {
  it('should call the appropriate service method based on the input', async () => {
    // ユーザー検索をシミュレート
    spyInstance.findOneWithQuery.mockImplementation(async () => [])
    spyInstance.findAll.mockImplementation(async () => [])
    
    // メソッドが呼び出された回数を確認
    expect(spyInstance.findOneWithQuery).toHaveBeenCalledTimes(0)
    expect(spyInstance.findAll).toHaveBeenCalledTimes(0)
    
    // email を渡してコントローラーの find を実行する
    await controller.find('test')
    // メソッドが呼び出された回数を確認
    expect(spyInstance.findOneWithQuery).toHaveBeenCalledTimes(1)
    expect(spyInstance.findAll).toHaveBeenCalledTimes(0)
    
    // 引数に何も渡さずにコントローラーの find を実行する
    await controller.find(undefined)
    // メソッドが呼び出された回数を確認
    expect(spyInstance.findOneWithQuery).toHaveBeenCalledTimes(1)
    expect(spyInstance.findAll).toHaveBeenCalledTimes(1)
  })
  it('should throw an error if the user is not found', async () => {
    // ユーザー検索をシミュレート
    spyInstance.findOneWithQuery.mockImplementation(async () => null)
    spyInstance.findAll.mockImplementation(async () => null)
    
    // email を渡してコントローラーの find を実行し、 null が返ってきた場合の結果が404であり想定エラーと一致していることを確認
    await expect(controller.find('test')).rejects.toThrow(
      new HttpException('User not found', HttpStatus.NOT_FOUND),
    )
    // 引数に何も渡さずにコントローラーの find を実行し、 null が返ってきた場合の結果が404であり想定エラーと一致していることを確認
    await expect(controller.find(undefined)).rejects.toThrow(
      new HttpException('User not found', HttpStatus.NOT_FOUND),
    )
  })
  it('should return a success message and the user data if the user is found', async () => {
    // ユーザー検索をシミュレート
    spyInstance.findOneWithQuery.mockImplementation(async () => mockUsers)
    spyInstance.findAll.mockImplementation(async () => mockUsers)
    
    // email を渡してコントローラーの find を実行し、ユーザーが取得でき場合200であり想定データと一致していることを確認
    const resultFindByEmail = await controller.find('test')
    expect(resultFindByEmail).toEqual({
      message: 'User successfully found',
      status: HttpStatus.OK,
      body: mockUsers,
    })
    // 引数に何も渡さずにコントローラーの find を実行し、ユーザーが取得でき場合200であり想定データと一致していることを確認
    const resultFindAll = await controller.find(undefined)
    expect(resultFindAll).toEqual({
      message: 'User successfully found',
      status: HttpStatus.OK,
      body: mockUsers,
    })
  })
})

find テスト説明

should call the appropriate service method based on the input

一つ目のテストでは引数がある場合、呼び出されるメソッドが変わることを確認するテストです。
コントローラーの find を実行した場合引数の有無によって想定したメソッドが実行しているかを確認します。

should throw an error if the user is not found

二つ目のテストではユーザーが取得できなかった場合、結果がエラーになっていることを確認するテストです。
findOneWithQuery と findAll どちらもユーザーが取得できなかった場合はエラーを結果として返しているので想定しているエラーが出ていることを確認します。

should return a success message and the user data if the user is found

三つ目のテストではユーザーが取得できた場合の確認するテストです。
findOneWithQuery と findAll どちらもユーザーが取れた場合ユーザーオブジェクトの配列が返ってきますので、想定している正常の型に合う内容が出ていることを確認します。

まとめ

実践を通じた学び

どこを担保すべきか、またそれが十分に担保されているかを理解し、何をテストし、何をテストしないかを明確にする必要があることを学びました。
データの信頼性も重要ですが、まずは自分が作成した箇所をしっかりとチェックすることが大切だと改めて認識しました。

今後の課題と発展可能性

今回はユニットテストに焦点を当てたため、データの整合性のテストや例外処理のテストについては触れていません。
データの整合性のテストは E2E テストや総合テストで確認する必要があります。

また、プロジェクトや現場によってはテストの考え方や文化が異なる場合もあります。
一概に本記事が正しいわけではありませんので柔軟に対応していく必要があります。

最後に

本記事では、テストコードの例を一部抜粋する形で紹介しました。
ぜひ本記事を参考にテストコードを完成させたり、自身のプロジェクトにテストを導入してみてください。

参考文献

4
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?