jest + TypeORM のデータベースを用いたテストで注意するべきこと

jest + TypeORM でデータベースを用いたテストをする際に注意すべきポイントを以下3つ紹介する。

おそらくこれらはこの環境に限らず、気をつけなければならないことのように思える。しかし、上記のポイントを無視してもなぜかテストが通るケースもあった。(後に単なる記述ミスの積み重ねが偶然起こしたことであることが判明)

そのため、同じように書いた別のテストはエラーが出た際には、原因がなかなか特定できず、時間をかけてようやく、上記の 3 つをおろそかにしていたことが主な要因であることが分かった。

よって今回はそのポイントとそれに従わなかった場合に出るエラー文をまとめることにした。 以下は正しく動作するコードである。

エンティティ

school と owner を 1 対 1,school と student を 1 対多のリレーションの例とした。
				
					# school.entity.ts

@Entity()
export class School {
  @PrimaryGeneratedColumn()
  readonly id: number;

  @Column()
  name: string;

  @OneToOne(() => Owner, (owner) => owner.school)
  owner?: Owner;

  @OneToMany(() => Student, (student) => student.school)
  students?: Student[];
}

# student.entity.ts

@Entity('students')
export class Student {
  @PrimaryGeneratedColumn()
  readonly id: number;

  @Column()
  name: string;

  @ManyToOne(() => School, (school) => school.students)
  @JoinColumn()
  school: School;
}

# owner.entity.ts

@Entity()
export class Owner {
  @PrimaryGeneratedColumn()
  readonly id: number;

  @Column()
  name: string;

  @OneToOne(() => School, (school) => school.owner)
  @JoinColumn()
  school: School;
}
				
			

投入データ

				
					# school.mock.ts

export const mockSchool = {
  id: 1,
  name: 'test-school',
};
# student.mock.ts

import { mockSchool } from './school.mock';

export const mockStudent = {
  id: 1,
  name: 'test-student',
  school: mockSchool,
};
# owner.mock.ts

export const mockOwner = {
  id: 1,
  name: 'test-owner',
  school: mockSchool,
};
				
			

サービス

school,owner,student をそれぞれ id から取得するサービスを定義した。
				
					@Injectable()
export class TestService {
  async findOneSchool(id: number) {
    const res = await getManager()
      .getRepository(School)
      .createQueryBuilder('schools')
      .where({ id: id })
      .innerJoinAndSelect('schools.students', 'students')
      .innerJoinAndSelect('schools.owner', 'owners')
      .getOne();
    return res;
  }

  async findOneStudent(id: number) {
    const res = await getManager()
      .getRepository(Student)
      .createQueryBuilder('students')
      .where({ id: id })
      .innerJoinAndSelect('students.school', 'schools')
      .getOne();
    return res;
  }

  async findOneOwner(id: number) {
    const res = await getManager()
      .getRepository(Owner)
      .createQueryBuilder('owners')
      .where({ id: id })
      .innerJoinAndSelect('owners.school', 'schools')
      .getOne();
    return res;
  }
}
				
			

テスト

テスト用データベースへデータの投入を行い、データを取得するサービスのテストにより、データベースへの保存ができているか確認する。
				
					describe('studentService', () => {
  let db: Connection;
  let testService: TestService;

  const saveAllData = async () => {
    await db.getRepository(School).save(mockSchool);
    await db.getRepository(Owner).save(mockOwner);
    await db.getRepository(Student).save(mockStudent);
  };

  const clearAllData = async () => {
    await db.manager.clear(Owner);
    await db.manager.clear(Student);
    await db.manager.clear(School);
  };

  beforeAll(async () => {
    db = await createConnection({
      type: 'sqlite',
      database: ':memory:',
      entities: [Student, School, Owner],
      dropSchema: true,
      synchronize: true,
      logging: true,
    });
    await saveAllData();
  });

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

    testService = module.get<TestService>(TestService);
  });

  beforeEach(async () => {
    await clearAllData();
    await saveAllData();
  });

  describe('findOne', () => {
    it('should return school', async () => {
      const res = await testService.findOneSchool(1);
      expect(res).toEqual({
        ...mockSchool,
        owner: { ...mockOwner, school: undefined },
        students: [{ ...mockStudent, school: undefined }],
      });
    });

    it('should return student', async () => {
      const res = await testService.findOneStudent(1);
      expect(res).toEqual(mockStudent);
    });

    it('should return owner', async () => {
      const res = await testService.findOneOwner(1);
      expect(res).toEqual(mockOwner);
    });
  });
});


				
			

出力データ

意図的にテストが失敗するようにして、受け取るデータを確認した。以下、Recieved: の箇所が受け取るデータである
				
					● studentService › findOne › should return school

  expect(received).toBe(expected) // Object.is equality

  Expected: ""
  Received: {"id": 1, "name": "test-school", "owner": {"id": 1, "name": "test-owner"}, "students": [{"id": 1, "name": "test-student"}]}

● studentService › findOne › should return student

  expect(received).toBe(expected) // Object.is equality

  Expected: ""
  Received: {"id": 1, "name": "test-student", "school": {"id": 1, "name": "test-school"}}

● studentService › findOne › should return owner

  expect(received).toBe(expected) // Object.is equality

  Expected: ""
  Received: {"id": 1, "name": "test-owner", "school": {"id": 1, "name": "test-school"}}

				
			
ここで3つのポイントについて、正しく記述されていないときのエラーも交えて解説する。

リレーションがあるデータは正しい順番で投入する

リレーションがある場合は親エンティティ(JoinCulumn がない方)が先に登録されるようにしなければならない。(ここでは school を先に save する必要がある。)
				
					await ownerRepository.save(mockOwner);
await schoolRepository.save(mockSchool);
await studentRepository.save(mockStudent);
				
			
上記のように owner を先に登録しようとすると以下のようなエラーが出る。
				
					FAIL  src/test/test.service.spec.ts
  ● studentService › findOne › should return school

    QueryFailedError: SQLITE_CONSTRAINT: FOREIGN KEY constraint failed

      at QueryFailedError.TypeORMError [as constructor] (error/TypeORMError.ts:7:9)
      at new QueryFailedError (error/QueryFailedError.ts:9:9)
      at Statement.handler (driver/sqlite/SqliteQueryRunner.ts:96:26)
				
			

リレーションのあるデータは、投入のデータ定義を正しく行う

親エンティティを先に save するので、以下のように親エンティティの投入データに子エンティティが含まれていると
				
					export const mockSchool = {
  id: 1,
  name: 'test-school',
  owner: mockOwner,
};
				
			
データの save が意図通りにされず、このように undefined が返ってきてしまう。
				
					● studentService › findOne › should return school

  expect(received).toEqual(expected) // deep equality

  Expected: {"id": 1, "name": "test-school", "owner": {"id": 1, "name": "test-owner", "school": undefined}, "students": [{"id": 1, "name": "test-student", "school": undefined}]}
  Received: undefined

● studentService › findOne › should return owner

  expect(received).toEqual(expected) // deep equality

  Expected: {"id": 1, "name": "test-owner", "school": undefined}
  Received: undefined
				
			

データを変更するサービスのテストはデータを投入し直す

createConnection で作成した DB はそのテストファイルで共通なので、create や update で保存されているデータに変更を加えると、その後のテストに影響することがある。

例えば、create でデータを作成するテストの後に findAll で全データを取得するテストをする場合には create が成功するかどうかで findAll の内容が変わってしまう。

テストする順番が結果に関わってしまうのは独立性が保たれないので、良いテストとは言えない。

従って、以下のようにテストごとにデータを初期化して、再度テストデータを投入することが必要になる。

				
					const saveAllData = async () => {
  await db.getRepository(School).save(mockSchool);
  await db.getRepository(Owner).save(mockOwner);
  await db.getRepository(Student).save(mockStudent);
};

const clearAllData = async () => {
  await db.manager.clear(Owner);
  await db.manager.clear(Student);
  await db.manager.clear(School);
};

beforeEach(async () => {
  await clearAllData();
  await saveAllData();
});
				
			
補足として、今回のこの環境では、clearAllData で School のエンティティを先に初期化してしまうと以下のようなエラーとなった。削除するときは子エンティティから消す必要があるのだろう。
				
					QueryFailedError: SQLITE_CONSTRAINT: FOREIGN KEY constraint failed

  at QueryFailedError.TypeORMError [as constructor] (error/TypeORMError.ts:7:9)
  at new QueryFailedError (error/QueryFailedError.ts:9:9)
  at Statement.handler (driver/sqlite/SqliteQueryRunner.ts:96:26)

				
			

終わりに

動くと思って書いたものが動かない時よりも、動かないと思って書いたものが動いてしまう時の方が怖いと思った。

スーパーソフトウエアの採用情報

あなたが活躍できるフィールドと充実した育成環境があります

blank
blank