[Keystone] テスト

テスト

Web アプリケーションを構築する際には、システムの動作をテストして予想通りに動作することを確認することが重要です。このガイドでは、@keystone-6/core/testing と Jest を使用して、GraphQL API の動作をテストするためのテストの書き方を紹介します。

テスト実行

@keystone-6/core/testing を使用してテストを実行するには、以下のスクリプトを package.json ファイルに追加することをお勧めします。

"scripts": {
  "test": "jest"
}

これにより、以下のコマンドでテストを実行できます。

yarn test

テストランナー

Keystone システムのテストを書くための最初のステップは、setupTestRunner を使用してテストランナーをセットアップすることです。その後、このランナーを使用してテスト関数をラップすることができます。

import { setupTestRunner } from '@keystone-6/core/testing';
import config from './keystone';

const runner = setupTestRunner({ config });

test(
  'Keystone test',
  runner(() => {
    // Write your test here
  })
);

テストランナーは、ここでいくつかのことを自動的に実行してくれます。まず、データベースへの接続を作成し、すべてのデータを削除します。これにより、すべてのテストが既知の状態で実行されることが保証されます。
次に、テストランナーは、GraphQL リクエストを処理するための Apollo サーバーを含む部分的な Keyston eシステムをセットアップします。このシステムには、管理者用 UI は含まれておらず、リクエストを待ち受けるためのネットワークポートも開かれていません。
最後に、ランナーはテストで使用するために3つの API をセットアップします。1つ目は、コンテキスト API の中の任意の関数を使用できる KeystoneContext オブジェクトです。2つ目は、supertest ライブラリを使用して HTTP 経由で GraphQL リクエストを実行できる graphQLRequest 関数です。3つ目は、supertest を使用して Express サーバーのエンドポイントにアクセスできる express.Express 値である app です。

テストの書き方

一般的には、Keystone システムの一部として書くカスタムコードの振る舞いをチェックするテストを実行することが望ましいです。これには、アクセス制御、フック、バーチャルフィールド、GraphQL API の拡張などが含まれます。

import { getContext } from '@keystone-6/core/context';
import { resetDatabase } from '@keystone-6/core/testing';
import * as PrismaModule from '.prisma/client';
import baseConfig from './keystone';

const dbUrl = `file:./test-${process.env.JEST_WORKER_ID}.db`;
const prismaSchemaPath = path.join(__dirname, 'schema.prisma');
const config = { ...baseConfig, db: { ...baseConfig.db, url: dbUrl } };

beforeEach(async () => {
  await resetDatabase(dbUrl, prismaSchemaPath);
});

const context = getContext(config, PrismaModule);

test('Your unit test', async () => {
  // ...
});

Context API

コンテキスト API を使用すると、システム内のデータを簡単に操作できます。基本的な CRUD 操作ができることを確認するために、Query API を使用できます。

const person = await context.query.Person.createOne({
  data: { name: 'Alice', email: 'alice@example.com', password: 'super-secret' },
  query: 'id name email password { isSet }',
});
expect(person.name).toEqual('Alice');
expect(person.email).toEqual('alice@example.com');
expect(person.password.isSet).toEqual(true);

この API は、操作が成功することを期待する場合にはうまく機能します。操作が失敗することを期待する場合には、context.graphql.raw 操作を使用して、クエリによって返されるデータとエラーが期待通りかどうかを確認できます。

// Create user without the required `name` field
const { data, errors } = await context.graphql.raw({
  query: `mutation {
    createPerson(data: { email: "alice@example.com", password: "super-secret" }) {
      id name email password { isSet }
    }
  }`,
});
expect(data.createPerson).toBe(null);
expect(errors).toHaveLength(1);
expect(errors[0].path).toEqual(['createPerson']);
expect(errors[0].message).toEqual(
  'You provided invalid data for this operation.\n  - Person.name: Name must not be empty'
);

context.withSession() 関数を使用すると、特定のユーザーとしてログインした場合のようにクエリを実行できます。これは、アクセス制御ルールの振る舞いをテストするために役立ちます。以下の例では、アクセス制御はユーザーが自分自身のタスクのみを更新できるようにしています。

// Create some users
const [alice, bob] = await context.query.Person.createMany({
  data: [
    { name: 'Alice', email: 'alice@example.com', password: 'super-secret' },
    { name: 'Bob', email: 'bob@example.com', password: 'super-secret' },
  ],
});

// Create a task assigned to Alice
const task = await context.query.Task.createOne({
  data: {
    label: 'Experiment with Keystone',
    priority: 'high',
    isComplete: false,
    assignedTo: { connect: { id: alice.id } },
  },
});

// Check that we can't update the task when logged in as Bob
const { data, errors } = await context
  .withSession({ itemId: bob.id, data: {} })
  .graphql.raw({
    query: `mutation update($id: ID!) {
      updateTask(where: { id: $id }, data: { isComplete: true }) {
        id
      }
    }`,
    variables: { id: task.id },
  });
expect(data!.updateTask).toBe(null);
expect(errors).toHaveLength(1);
expect(errors![0].path).toEqual(['updateTask']);
expect(errors![0].message).toEqual(
  `Access denied: You cannot perform the 'update' operation on the item '{"id":"${task.id}"}'. It may not exist.`
);

graphQLRequest API

コンテキスト API はほとんどのユースケースをカバーしますが、特定の HTTP 関連の振る舞いをテストする必要がある場合は、graphQLRequest API を使用できます。この API を使用すると、リクエストと一緒に送信される HTTP ヘッダーなどの詳細を制御でき、返されるコードを含む完全な HTTP レスポンスを返します。 graphQLRequest 関数は、{ query、variables、operationName } オブジェクトを受け取り、supertest テストオブジェクトを返します。

runner(async ({ graphQLRequest }) => {
  const response = await graphQLRequest({
    query: `mutation {
      createPerson(data: { name: "Alice", email: "alice@example.com", password: "super-secret" }) {
        id name email password { isSet }
      }
    }`,
  })
    .set('X-Example-Header', 'header-value')
    .expect(200);

  const person = response.body.data.createPerson;
  expect(person.name).toEqual('Alice');
  expect(person.email).toEqual('alice@example.com');
  expect(person.password.isSet).toEqual(true);
})

Express app

Express サーバーの特定のエンドポイントと直接やり取りしたい場合があります。アプリケーションにアクセスするには、app として公開されている Express アプリケーションを使用し、supertest を使ってやり取りすることができます。たとえば、/_healthcheck エンドポイントを確認したい場合は、次のようにします。

runner(async ({ app }) => {
  const { text } = await supertest(app)
    .get('/_healthcheck')
    .set('Accept', 'application/json')
    .expect('Content-Type', /json/)
    .expect(200);
  expect(JSON.parse(text)).toMatchObject({ status: 'pass' });
})

テスト環境

テストランナー関数は、すべてのテストのためにデータベースをクリーンな状態にリセットします。これにより、1つのテストでデータの状態を変更しても、他のテストに影響を与えないようにします。
毎回データを初期化すると、大量のデータをシードする必要がある場合にコストがかかることがあります。これらの場合は、テスト間でデータベースの状態を共有してリセットすることなく複数のテストを実行する必要があります。これは、setupTestEnv を使用して実現できます。
setupTestEnv 関数は、システムを初期化し、データベースからすべてのデータを削除した後、テストの実行方法を制御するオブジェクトを返します。返される値には、通常、テストグループの beforeAll と afterAll ブロックで呼び出す connect と disconnect 関数が含まれます。また、テストランナー関数によって提供される引数を含む testArgs も返されます。
beforeAll() ブロックで connect() を呼び出した後、context API を使用してデータベースを初期化し、テストブロック内のすべてのテストで使用されるようにすることができます。

import { setupTestEnv, TestEnv } from '@keystone-6/core/testing';
import { KeystoneContext } from '@keystone-6/core/types';

describe('Example tests using test environment', () => {
  let testEnv: TestEnv;
  let context: KeystoneContext;

  beforeAll(async () => {
    testEnv = await setupTestEnv({ config });
    context = testEnv.testArgs.context;

    await testEnv.connect();

    // Initialise database state here
  });

  afterAll(async () => {
    await testEnv.disconnect();
  });

  test('Test 1', async () => {
    ...
  });

  test('Test 2', async () => {
    ...
  });
});