39
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Nest.js における Jest を用いた typeORM repository のテスト

Last updated at Posted at 2020-07-27

誰向け ??

Nest.jsでのJestを用いたtypeormのrepositoryテストについて知りたい人。悩んでいる人。

背景

最近少しずつ使用者の増えているNest.jsですが、まだ日本語ドキュメントもありません。
さらにtypeORMというORマッパーを使い、そのテストをJestで書いている人の記事は日本語では数えるほど。(英語だと沢山あります)
そして本当に不思議なことにその方達が書いているテストはserviceばかり。repositoryについての記事なんて全然ありません。そんな中でテストを書いてこいと言われてガチで30回くらい挫折した私の苦悩とついにたどり着いたグリーンなテストについて紹介しようと思います。

この記事が末長くnestのテストで苦しむ人の助けになればと思います。この記事には自分の苦悩も綴っておくので、通るテストだけ知りたいという人は最後までスクロールしてください!

テストが緑でpassした時は本当に涙が出ました。

pose_kandou_man.png

環境

DB: Mysql
言語: typescript

実装

いよいよ実装に入っていきます

  1. repositoryでの処理の確認
  2. テストコード

の流れで説明していきます。今回はNestの仕組み自体(そもそもrepositoryとは、など)やjestの記法についての解説は省きます。

repositoryでの処理

今回は以下のようなレポジトリについてのテストを書きます。

createItemInputという引数を受け取り、Itemというentityを作成して返すcreateItemというメソッドを持ったitemRepositoryです。


item: {
  date: string,
  price: number,
  method: string,
  user: User (他ファイルで定義されたEntity)
}
item.repository.ts
export class ItemRepository extends Repository<Item> {
  async createItem(
    createItemInput: CreateItemInput,
  ) {
    const {
      date,
      price,
      method,
      userId
    } = createItemInput;

    // 1. itemを作成し、各種プロパティに代入していく
    const item = new Item();
    item.date = date;
    item.price = price;
    item.method = method;
    // userをuserRepositoryから取得してitemに代入
    const user = await getRepository(User).findOne({id: userId});
    item.user = user;

    // 2. itemを保存するときのtransaction処理の開始
    const queryRunner = getConnection().createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const savedItem = await queryRunner.manager.save(
        Item,
        item,
      );
      await queryRunner.commitTransaction();
      return item;
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw new InternalServerErrorException();
    } finally {
      await queryRunner.release();
    }
  }
}

一見なんてことないコードなんですが、テストをするとなるとなかなか一筋縄でいきません。

テストを書いてみる

難所その1 : getRepository()が呼び出せない

いざこのテストを書こうとするとこんな思考回路になると思います

んー、そうだ、テストファイル内でgetRepositoryで新しくItemRepositoryとってきて、そこに適切な引数を渡してちゃんとitemが返ってくるか見ればいいな。よし、書けたぞ。

item.repository.spec.ts
it('should create item', () => {
  const repo = getRepository(Item)
  repo.createItem(hoge)
  ...
})

よし、実行してみよう。

ConnectionNotFoundError: Connection "default" was not found.

おめでとうございます!!本日1人目の死亡者が確認されました。
test環境だとデータベースに繋がっていない環境で処理が行われるので、こんな感じで**「データベースとのConnectionがないぞ」**って怒られます。ちなみにこれはテストファイルに限らず、item.repository.ts内部でのgetRepository()でもこのエラーがでます。

それならとりあえずテストファイルではnew ItemRepository()で回避するか。でもItemRepository内部で呼び出されるgetRepository()の問題はどう解決すればいいんだ...?

はい、jestのドキュメントに書いてありました。jest.spyOn()を使うと呼び出し先のモジュールをモック?スパイ?することができます。(https://jestjs.io/docs/ja/jest-object#jestspyonobject-methodname)

これを実装するとこんな感じになります。ついでにfindOneメソッドをモックしておきました。

const spyGetRepository = jest
    .spyOn(TypeOrm, 'getRepository')
    .mockImplementation(() => {
      const repo = new UserRepository();
      repo.findOne = jest.fn().mockResolvedValue(new User());
      return repo;
    });

これにてgetRepository()は無事エラーを吐かなくなりました🎉

難所その2 : getConnection()が呼び出せない

先ほどのgetRepository()と同じ理由でgetConnection()がエラーを吐きます。

またこれね、さっきと同じ方法で解決したろ。よし、これで行けるはず。

const spyGetConnection = jest
        .spyOn(TypeOrm, 'getConnection')
        .mockImplementation(() => new Connection())

これだとTypescriptの構文解析先生に怒られます。Connectionの引数に無数の引数を入れる必要があるからです。これを解決するためにtypescriptのasを用います。

const mockConnection = {} as Connection;

これだとなんとか解析を潜り抜けることができます。しかし、ConnectionにはQueryRunnerクラスを返すcreateQueryRunnerメソッドなどがあるので、これらも追加する必要があります。

const spyGetConnection = jest
        .spyOn(TypeOrm, 'getConnection')
        .mockImplementation(() => {
          const mockConnetion = {} as TypeOrm.Connection;
          mockConnetion.createQueryRunner = jest.fn().mockImplementation(() => {
            // ここでQueryRunnerを作成してreturnする
          });
          return mockConnetion;
        });

よし、あとはConnectionと同じノリでQueryRunnerも作成したら終わりだ!!さっきのrepositoryの処理を見直してQueryRunnerの内部に何をモックすればいいか振り返ってみるか

queryRunner.connect();
queryRunner.startTransaction();
queryRunner.manager.save(args)
...

なるほど、connect()とかはjest.fn()でモックすればいいから、managerとかいうクラスを作ってその下にsaveメソッドを生やせばいいんだな。これでどうだ


const spyGetConnection = jest
        .spyOn(TypeOrm, 'getConnection')
        .mockImplementation(() => {
          const mockConnetion = {} as TypeOrm.Connection;
          mockConnetion.createQueryRunner = jest.fn().mockImplementation(() => {
            const qr = {} as TypeOrm.QueryRunner;
            // ↓ここでエラー!!!!
            qr.manager = {save: jest.fn()} as TypeOrm.Manager;
            // ↑ここでエラー!!!!
            qr.connect = jest.fn();
            qr.release = jest.fn();
            qr.startTransaction = transactionMock.start;
            qr.commitTransaction = transactionMock.commit;
            qr.rollbackTransaction = transactionMock.rollback;
            qr.release = transactionMock.release;
            return qr;
          });
          return mockConnetion;
        });

すると、

managerは読み取り専用プロパティであるためmanagerに代入することはできません

saigai_teiden.png

もうこのエラーが出てきた時に目の前が真っ暗になりました。readonlyプロパティをどうやって設定してmockすればいいんだと。

これも悩みに悩んだ結果以下の方法で解決することができます。

const mockConnetion = {} as TypeOrm.Connection;
mockConnetion.createQueryRunner = jest.fn().mockImplementation(() => {
  const qr = {
    manager: {},
  } as TypeOrm.QueryRunner;
  qr.manager = {}
  Object.assign(qr.manager, {
    save: jest.fn().mockResolvedValue({ id: 1 }),
  });
});

初期化段階でmanagerを入れておいて、Object.assignで置き換えることで解決します。これにて全ての苦難をクリアです。おめでとうございます🎉🎉

最終的なテストコード

お待たせしました。これが紆余曲折を経てたどり着いたコードです。バッドプラクティスな書き方をしていたらご指摘ください!(また、これは本物のコードの一部を取り出してきたものなのでそのままでは動かないと思われます)

item.repository.spec.ts

describe('create', () => {
  /*
  テスト環境ではdbに繋がっていないのでgetRepositoryなどのdbConnection周りのメソッドがエラーを吐くのでモックする必要がある。
  */
  const spyGetRepository = jest
    .spyOn(TypeOrm, 'getRepository')
    .mockImplementation(entity => {
      return new UserRepository()
    });
  let transactionMock;
  beforeEach(() => {
    transactionMock = {
      start: jest.fn(),
      commit: jest.fn(),
      rollback: jest.fn(),
      release: jest.fn(),
    };
  });
  // 正常系動作のテスト
  describe('normal behavior', () => {
    beforeEach(() => {
      /* 
        上記と同様の理由でConnection周りをspyしていく。正常系の動作を規定。
        対象は
        Connectionを返すgetConnection()関数
        QueryRunnerクラスを返すcreateQueryRunnerメソッド、を持つConnectionクラス
        各種メソッド(connect(), startTransaction(), etc...)を持つQueryRunnerクラス
      */
      const spyGetConnection = jest
        .spyOn(TypeOrm, 'getConnection')
        .mockImplementation(() => {
          const mockConnetion = {} as TypeOrm.Connection;
          mockConnetion.createQueryRunner = jest.fn().mockImplementation(() => {
            // QueryRunnerを作成。managerがreadonlyプロパティなので初期化段階で入れておき、Object.assignで変更する必要がある。
            const qr = {
              manager: {},
            } as TypeOrm.QueryRunner;
            qr.manager = {}
            Object.assign(qr.manager, {
              save: jest.fn().mockResolvedValue({ id: 1 }),
            });
            // QueryRunnerに必要なメソッドをモックしていく
            qr.connect = jest.fn();
            qr.release = jest.fn();
            qr.startTransaction = transactionMock.start;
            qr.commitTransaction = transactionMock.commit;
            qr.rollbackTransaction = transactionMock.rollback;
            qr.release = transactionMock.release;
            return qr;
          });
          return mockConnetion;
        });
    });
    it('should create procurement', async () => {
      // createProcurementを発火
      const returnedItem = await mockItemRepository.createItem(引数);
      // Itemが返ってきているかのテスト
      expect(returnedItem).toEqual(
        expect.objectContaining({
          price: expect.anything(),
          ...その他
        }),
      );
      // 正しく処理が行われていればtransactionのrollbackだけ呼ばれない
      expect(transactionMock.start).toHaveBeenCalled();
      expect(transactionMock.commit).toHaveBeenCalled();
      expect(transactionMock.rollback).not.toHaveBeenCalled();
      expect(transactionMock.release).toHaveBeenCalled();
    });
  });

終わりに

私がこのグリーンなテストをかけるまで2週間くらい悩み続けました。本当に何度も諦めかけましたが、同僚の助けもあり無事解決することができました。皆さんのNestライフが充実するものになることを祈っています。参考になったという方はぜひLGTMやストックお願いいたします!!

39
15
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
39
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?