📝

単一のテストケースでAPIテストと結合テスト両方に対応させる削除戦略

2023/12/11に公開

はじめに

APIテスト:Staging環境へのテスト
結合テスト:ローカル環境でDBなどをコンテナでモックしたテスト

一つの.specファイルで結合テストをしつつ、デプロイ前の最終動作チェックとして、APIテストを同等に実施するテストコードを書くために考慮すべきことをあげていきます。

テストで作成したテストデータのみを削除する方法

ローカル環境では、データを削除するためにデータベースをリセットすれば十分です。しかし、ステージング環境でのテストでは、テスト後にデータベースを元に戻すことは、全てのデータを失うことを意味し、それは許されません(APIテストには既存のデータが必要です)。テストケースを一つ実行するたびに元に戻すと、時間が無駄になるでしょう。したがって、テストデータだけを効率的に削除する仕組みを実装することが求められます。

そこで、削除戦略が必要になってきます。
例として、ECサイトのテストケースを考えます。

user.entity
| UserID |  Username  |   Email           | 
|--------|------------|-------------------|
|   1    |  user1     |  user1@email.com  |
|   2    |  user2     |  user2@email.com  |
|   3    |  user3     |  user3@email.com  |
purchase.entity
| UserID | ProductID |   ProductName   |
|--------|-----------|-----------------|
|   1    |    1001   |  Product A      |
|   2    |    1002   |  Product B      |
|   1    |    1003   |  Product C      |
|   3    |    1001   |  Product A      |
product.entity
| ProductID |   ProductName   |  Price |  Stock  |
|-----------|-----------------|--------|---------|
|   1001    |  Product A      |  $20   |   100   |
|   1002    |  Product B      |  $30   |    50   |
|   1003    |  Product C      |  $40   |    30   |

テストオブジェクトとして、以下のクラスを作成します。
考え方としてはテストデータの生成時にテストデータと判定できるキー(例:UserId)を記録しておき、削除時に、それらをキーとして削除を実施します。

mock.ts
class Mock {
  private users: User[]; // システムに作用(購入など)するユーザ
  private operator: Operator; // システムに作用(商品の登録など)するオペレータ
  private db: mockDb; // = localDb | remoteDb
  createUser(name:string): User
  getUser(name: string): User
  getOperator(): Operator
  clear(); // 1. UserIdをキーに、すべてのテーブルに対し削除を実施
           // 2. ProductIdをキーに、すべてのテーブルに対し削除を実施
}

商品を購入するAPIを叩くためにはUserクラスが必須になる。Mock経由以外でのAPIコールを防ぐ。

user.ts
class User {
   constructor(db:mockDB)
   createAccount()
   userId: userId; // ユーザIDや認証情報など
   api: Client; // ユーザが呼び出せるAPIを記述
}

商品を登録するためには、Operator>Prodcutを経由しなければいけない。

operator.ts
class Operator {
   constructor(db:mockDB)
   private products: Prodcut[];
   ...
   addProduct(name:string):prodcut; // DBにINSERTしつつ、products.push()する。
   clearProdcut():product[]; // productsをキーに削除する。
}
db.ts
class remoteDb extends mockDb{
  saveEntity()... // access remote DB
  queryEntity()... // access remote DB
}
class localDb extends mockDb {
  saveEntity()... // access local DB
  queryEntity()... // access local DB
}

テストコード

test.spec.ts
it("ユーザが商品を購入する",()=>{
   // ユーザを作成
   const user1 = mock.createUser("user1");
   // 商品を登録
   const productA = mock.getOperator().addProduct("ProductA",10);
   const before = mock.db.countProdcut("ProductA"); // テスト前の商品数
   user1.createAccount();
   user1.api.AddToCart(productA);
   user1.api.Purchase();
   const after = mock.db.countProdcut("ProductA"); // テスト後の商品数
   expect(after).toEqual(before-1); // Stagingにおいて、ProductAが既にある可能性を考慮して、 `toEqual(9)`ではなく`toEqual(before - 1)`
})

注意点

  • このテストでは結合テストとAPIテストに両対応させるために制約を受けます。
    • 既存のデータがあったとしても動作するテストにする必要があります。
    • APIテストの実施中にテスト外からの処理を受け付けると失敗する可能性があります。
      • 一時的にNWを遮断するなどの措置が必要
    • 結合テストとAPIテストが混在して実施するので、テストの幅はAPIテストに縛られるため、異常系のテストでできないパターンが存在します。(DBのタイムアウトを意図的に発生させる等)
  • 結合テストで十分に検証できたものをAPIテストで実施しましょう。
  • ローカルでは成功しても、結果整合性などにより値が更新されていない可能性があります。
    • APIのリクエスト等の関数の中にDelay関数を挟んで、ステップごとに十分な待ちを発生させましょう。

Discussion