Amplify Mockingを利用してGraphQL API のユニットテストをする

どうも、じゃがです！

本記事は [AWS Amplify Advent Calendar 2020](https://qiita.com/advent-calendar/2020/amplify) の9日目のエントリになります
2020年はAmplify iOS, AndroidのGA、Amplify JSのSSR対応やFlutterのDeveloper Preview、そしてAmplify Admin UIの登場など、Amplifyのアップデートが楽しい一年でしたね！
今回はGraphQL APIのユニットテストを書くノウハウについてまとめてみたいと思います！

# 想定読者

* AmplifyでGraphQL APIのテストを書きたい人
* テストが要件に入ってくる開発でもAmplify使いたい人

Amplify の基本的な使い方については解説しませんのでご了承ください。手を動かしてAmplify学びたいとうかたは、以下のWorkshopもご参照ください

* https://amplify-sns.workshop.aws/ja/

# 動作確認環境

* @aws-amplify/cli v4.34.0
* 本記事で作成したコード: https://github.com/jaga810/amplify-graphql-test

# GraphQL APIのテストとは？

Amplify CLIでGraphQL APIを構築する際、Amplify Mockingを用いてAPIレスポンスが期待通り返ってくるか、手元で試しながら実装される方は多いのではないでしょうか。(Amplify Mockingを使ったことがないという方はぜひ[GraphQL API開発スピードを爆上げするAWS Amplify Mockingことはじめ] (https://qiita.com/nagym/items/638974a3a5aaa63841c8)をご参照ください)

一方で開発中、継続的にユニットテストを実行したいというニーズもあるかと思います。ユニットテストがあれば今まで実装した部分が新たなコードの変更で崩れないことを担保でき、開発のスピードも質も向上します

Amplify CLIのGraphQL APIでユニットテストはどのように実施すればよいでしょうか？

本記事ではJavaScriptテスティングフレームワークの[Jest](https://jestjs.io/ja/)を用いて、Amplify Mockingで立ち上げたローカルサーバーへGraphQL Operationを行い、挙動をテストするという方針でユニットテストを行います
また、そのテストをAmplify Consoleを利用したCI/CDパイプラインで動かします

# GraphQL APIの作成

## Amplify プロジェクトの初期化

```bash:shell
#適当な作業ディレクトリで以下のコマンドを実行します
$ mkdir graphql-test; cd $_
$ amplify init
#amplify initで聞かれる項目は全てデフォルトの選択肢で大丈夫です
```

## GraphQL API の作成

```bash:shell
$ amplify add api
#amplify add apiで聞かれる項目は、認証方法でCognitoを選ぶこと以外は全てデフォルトの選択肢で回答します
```

![image (13).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/f4d83a22-c943-dd3d-385b-e27c04d058b8.png)


シンプルなスキーマを持つ Todo モデルが出来上がりました

```amplify/backend/api/graphqltest/schema.graphql
type Todo @model {
  id: ID!
  name: String!
  description: String
}
```

認可の仕組みを実装するために、このスキーマを以下のように書き換えます

```amplify/backend/api/graphqltest/schema.graphql
type Todo
  @model
  @auth(
    rules: [
      {allow: owner, ownerField: "owner", operations: [create, read, update, delete]},
    ]
  )
{
  id: ID!
  name: String!
  description: String
}
```

これにより最初にTodoを作成した`owner`だけが、read/update/delete処理を実行できるようになりました


## 動作確認

ユニットテストではローカル環境でGraphQL APIの動作確認が可能なAmplify Mockingを利用します。Amplify Mockingは内部的に、[amplify-appsync-simulator](https://www.npmjs.com/package/amplify-appsync-simulator)と、[DynamoDB Local](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/DynamoDBLocal.html)を利用しています

```bash:shell
$ amplify mock api
#amplify mock apiで聞かれる項目はすべてデフォルトの選択肢で回答します
...
AppSync Mock endpoint is running at http://192.168.1.2:20002 (http://192.168.1.2:20002/)
```

最後に表示されたエンドポイントで[amplify-appsync-simulator](https://www.npmjs.com/package/amplify-appsync-simulator)が立ち上がっており、アクセスすると以下のようなAmplify GraphiQL Explorerが表示されます

![image (14).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/8a1d730e-6f6b-81bd-2cab-680b142bfd85.png)


# ユニットテストの実行
## Jestの導入

まずは`package.json`を作成します

```bash:shell
$ npm init
#test commandのみ jest と入力しましょう。$ npm testコマンドでjestが走るようになります
```

Jestをはじめとしたテストに必要なライブラリをインストールします

```bash:shell
$ npm install —save-dev jest babel-jest babel-plugin-transform-es2015-modules-commonjs
```

また、プロジェクト直下に以下のファイルを作成します

```json:.babelrc
{
  "env": {
      "test": {
          "plugins": [
              "transform-es2015-modules-commonjs"
          ]
      }
  }
}
```

```jest.config.js
module.exports = {
  transform: {
      '^.+\\.js$'  : '<rootDir>/node_modules/babel-jest',
  },
  moduleFileExtensions: ['js']
}
```

## @authのテストを書く

@authで指定した通りに、ownerしかupdateができないことを確認するユニットテストを書いていきます

テスト内で必要なライブラリをインストールします

```bash:shell
npm install —save-dev graphql-request graphql crypto base64url
```

Amplify JavaScriptのライブラリはAppSyncに渡すヘッダを自由に変更できないので、軽量なgraphql-requestをGraphQLクライアントとして利用します
cryptoとbase64urlはCognitoのJWTトークンを擬似的に生成するために利用します

プロジェクト直下に以下のファイルを作成します

```auth.test.js
import { GraphQLClient } from 'graphql-request';
import crypto from 'crypto';
import base64url from 'base64url';

import { createTodo, updateTodo } from './src/graphql/mutations';

const cognitoJwtGenerator = ({username}) => {
  const header = {
    'alg': 'HS256',
    'typ': 'JWT'
  }

  const payload = {
    'sub': '7d8ca528-4931-4254-9273-ea5ee853f271',
    'cognito:groups': [],
    'email_verified': true,
    'algorithm': 'HS256',
    'iss': 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_fake_idp',
    'phone_number_verified': true,
    'cognito:username': username,
    'cognito:roles': [],
    'aud': '2hifa096b3a24mvm3phskuaqi3',
    'event_id': '18f4067e-9985-4eae-9f33-f45f495470d0',
    'token_use': 'id',
    'phone_number': '+12062062016',
    'exp': 16073469193,
    'email': 'user@domain.com',
    'auth_time': 1586740073,
    'iat': 1586740073
  }

  const encodedHeaderPlusPayload = base64url(JSON.stringify(header)) + '.' + base64url(JSON.stringify(payload));

  const hmac = crypto.createHmac('sha256', 'secretKey')
  hmac.update(encodedHeaderPlusPayload)
  
  return encodedHeaderPlusPayload + '.' + hmac.digest('hex');
}

//2ユーザーからリクエストを行えるよう2つのクライアントを作成
const testUsers = ['user_0', 'user_1'];
const clients = [];

clients.push(new GraphQLClient('http://localhost:20002/graphql', {
  headers: {
    Authorization: cognitoJwtGenerator({username: testUsers[0]})
  },
}));

clients.push(new GraphQLClient('http://localhost:20002/graphql', {
  headers: {
    Authorization: cognitoJwtGenerator({username: testUsers[1]})
  },
}));

describe('Todo Model', () => {
  test('Only owner can update their todos', async () => {
    const testTodo = {
      name: 'Test task',
      description: 'This is a test task for unit test',
    };

    // Test用Todoの作成
    const created = await clients[0].request(createTodo, {input: testTodo});

    // Owner自身によるUpdateが成功することを確認
    const updatedName = 'Updated Test Task by user_1';
    const updatedByOwner = await clients[0].request(updateTodo, {input: {id: created.createTodo.id, name: updatedName}});
    expect(updatedByOwner.updateTodo.name).toStrictEqual(updatedName);

    // Owner以外によるUpdateが失敗することを確認
    const updatedByOthers =  clients[1].request(updateTodo, {input: {id: created.createTodo.id, name: ''}});
    await expect(updatedByOthers).rejects.toThrowError('ConditionalCheckFailedException');
  });
});
```

テストを実行してみましょう。( $ amplify mock api が動いていることを確認してください)

```bash:shell
$ npm run test
graphql-test@1.0.0 test /Users/daisnaga/Dev/amplify-playground/graphql-test
> jest

PASS ./auth.test.js
Todo Model
✓ Only owner can update their todos (166 ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.65 s
Ran all test suites.
```

テストが通りました！

# CI/CDパイプライン (Amplify Console) でGraphQL APIのユニットテストを実行する

## 1コマンドでAppSync Simulatorの起動からテストの実行までを行う

これまでのように`$ amplify mock api`を起動しておきつつ、別のシェルで`$ npm run test`を実行するのは、手元ではAppSync Simulatorの開始処理を何度も行わないで済むというメリットがある一方、Amplify ConsoleなどCI/CDパイプラインを利用する際は不便です。1コマンドでAppSync Simulatorの起動からテストの実行までを行うため、[bahmutov/start-server-and-test](https://github.com/bahmutov/start-server-and-test) を用います。


```bash:shell
$ npm install —save-dev start-server-and-test
```

package.jsonのscriptsに以下を追記します。

```json:package.json
{
  ...
  "scripts": {
    "test": "jest",
    "start-server": "amplify mock api",
    "ci": "start-server-and-test start-server http://localhost:20002 (http://localhost:20002/) test“
  },
  ...
}
```

これにより、`$ npm run ci`実行時に以下の処理が行われます

1. 第一引数の`start-server`、すなわち`amplify mock api`コマンドの実行によりAmplify Mockingを開始
2. 第二引数の`http://localhost:20002`を監視し、[amplify-appsync-simulator](https://www.npmjs.com/package/amplify-appsync-simulator)の起動を待機
3. 第三引数の `test` すなわち `jest` を実行
4. テストの実行終了後、Amplify Mockingを停止

実際に試してみましょう。($ amplify mock api が停止していることをご確認ください)

```
$ npm run ci
```

ユニットテストが無事実行できたかと思います。(途中AppSync Simulatorのエラーが出力されますが、期待通りの振る舞いです)


## Amplify Consoleのセットアップ
下ごしらえができたので、Amplify Console でテストを実行します。
ここではGitHubにこれまでのコードをpushする流れはSkipさせていただきます。


1. AWSのマネージメントコンソールからAmplify Consoleを開きます
2. 右上の New app > Host web app をクリックします
3. GitHubを選択し、 Continueをクリックします
4. リポジトリブランチの追加でpushしたブランチをrepoとブランチを選択します
5. ここまで $ amplify pushを実行していないので、ビルド設定の追加ではCreate new environmentを選択し、env名には好きな名前(mainline など)を入力します。
6. ビルド設定の項目で、テスト項目を追加します
7. 「次へ」をクリックし、「保存してデプロイ」します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/4e2656aa-c005-63f6-9e98-87280fe0afe1.png)


数分待つとビルドとテストがクリアされ、デプロイされたことを確認できます。

## ビルド設定にGraphQL APIのユニットテストを足す
このままだと特にユニットテストもせずデプロイされてしまいます。テストの設定を追加してみましょう

アプリの設定 > ビルドの設定 > ビルド設定の追加で編集をクリック
![Screen Shot 2020-12-09 at 9.11.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/e778aad7-96f4-ae9e-0f93-c9681ee9c674.png)

以下のように書き換え、「保存」します

```amplify.yml
version: 1
    
backend:
  phases:
    # IMPORTANT - Please verify your build commands
    preBuild:
      commands:
        - amazon-linux-extras enable corretto8 
        - yum install -y java-1.8.0-amazon-corretto java-1.8.0-amazon-corretto-devel
        - npm ci
        - amplify pull --appId ${APP_ID} --envName main -y 
        - # Amplify Mockingの実行に必要なAmplifyプロジェクトの情報をpull
        - # ${APP_ID}はご自身のIDに置き換えてください。Amplify ConsoleでAppを開いて、#/の次の文字列です。(例: d3j54ikssyzl4d)
    build:
      commands:
        - '# Execute Amplify CLI with the helper script'
        - npm run ci && amplifyPush --simple #ユニットテストが通った時のみデプロイ
frontend:
  phases:
    preBuild:
      commands:
        - npm ci
    build:
      commands: []
  artifacts:
    # IMPORTANT - Please verify your build output directory
    baseDirectory: /
    files:
      - '**/*'
  cache:
    paths:
      - node_modules/**/*
```

(注1) Amplify Consoleのビルド環境はAmazon Linux2で、$ amplify mock api に必要なJavaランタイムが入っていません。そのため本記事ではAWSが提供する無償OpenJDK Distributionである [Amazon Corretto](https://aws.amazon.com/jp/corretto/) をビルド時にインストールしています。ただしこれではCI/CDパイプラインが動くたびにJavaをインストールすることになり、ビルドの実行時間が伸びてしまいます。実際に利用するときは[Custom build images](https://docs.aws.amazon.com/ja_jp/amplify/latest/userguide/custom-build-image.html)機能を用いて、Javaをインストール済みのコンテナを利用することをおすすめします。

(注2) [Add end-to-end tests to your app](https://docs.aws.amazon.com/ja_jp/amplify/latest/userguide/running-tests.html)のように、`test`セクションを用いる事も可能ですが、`test`セクションに書いた内容は`backend`と`frontend`のセクションが実行された後に実行されます。その場合`$ npm run ci`を用いたユニットテストが通ろうが通るまいが、バックエンドリソースが更新されてしまいます。そのため本記事では `npm run ci && amplifyPush --simple`とすることで、ユニットテストが通らない場合にバックエンドリソースの更新をしないようにしています。


ビルドのページに戻って「このバージョンを再デプロイ」ボタンをクリックします。
![Screen Shot 2020-12-09 at 9.19.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/d61a2666-7492-9a49-6364-9bc618391c87.png)


数分待つとビルドとデプロイが実行されました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/2f3395de-cb45-fabe-bb07-156faf47bf31.png)

ユニットテストが実行され、パスしてることが確認できます。

## ユニットテストが通らなかった場合の挙動の確認
ユニットテストが通らなかった場合に、デプロイが実行されないことを確認しましょう
`auth.test.js`に必ず失敗するテストを追加します

```auth.test.js
...

describe('Todo Model', () => {
  //[追加部分]必ず失敗するテスト
  test('must fail', () => {
    expect(0).toStrictEqual(1);
  })

  //以下は同じ
  test('Only owner can update their todos', async () => {
    const testTodo = {
      name: 'Test task',
      description: 'This is a test task for unit test',
    };

    // Test用Todoの作成
    const created = await clients[0].request(createTodo, {input: testTodo});

    // Owner自身によるUpdateが成功することを確認
    const updatedName = 'Updated Test Task by user_1';
    const updatedByOwner = await clients[0].request(updateTodo, {input: {id: created.createTodo.id, name: updatedName}});
    expect(updatedByOwner.updateTodo.name).toStrictEqual(updatedName);

    // Owner以外によるUpdateが失敗することを確認
    const updatedByOthers =  clients[1].request(updateTodo, {input: {id: created.createTodo.id, name: ''}});
    await expect(updatedByOthers).rejects.toThrowError('ConditionalCheckFailedException');
  });
});
```

この内容をgitにpushし、再度デプロイの様子をみてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/8de7157d-5ed7-f0b7-395a-73ce2bba246b.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532688/06e53ed4-fcc8-caa3-6e05-6e20205297d2.png)

確かにビルドフェーズで失敗しており、`amplifyPush --simple`が実行されないことが確認できました！

# Tips & etc

## リクエストヘッダの生成
AmplifyのGraphQL APIで利用するAppSyncではCognito、IAM、API_KEY、OIDCの4つの認証方法が利用できます。
ここでは、IAM、API_KEY認証のリクエストヘッダの作り方をご紹介します。
(著者の環境ではAmplify MockingのOIDC認証が`UnauthorizedException`になってしまい解決できず、、ここでは割愛させていただきます...><)

### IAMの場合
リクエストヘッダに以下を付与します

```js
const iam_key_client = new GraphQLClient('http://localhost:20002/graphql', {
  headers: {
    'Authorization': 'AWS4-HMAC-SH256 IAMAuthorized'
  }
})
```

### API_KEYの場合

```js
const api_key_client = new GraphQLClient('http://localhost:20002/graphql', {
  headers: {
    'x-api-key': 'da2-fakeApiId123456'
  }
})
```

## Seed Dataの作成

残念ながらAmplify CLIのAmplify MockingではSeed Dataの作成をサポートしていません。
PFRが上がっているので清き+1をぜひ！
https://github.com/aws-amplify/amplify-cli/issues/2563#issuecomment-541873258

## VTL単体のテスト

Amplify CLI を使っていると生VTLを書くことはほとんどありませんが、Custom VTL単体でtestしたい場合は、@G-awaさん著 [Effective AppSync 〜 Serverless Framework を使用した AppSync の実践的な開発方法とテスト戦略 〜 の「VTLをテストする」の項目](https://qiita.com/G-awa/items/095faa9a94da09bc3ed5#vtl-%E3%82%92%E3%83%86%E3%82%B9%E3%83%88%E3%81%99%E3%82%8B
)が非常に参考になります。

# まとめ
Amplify Mockingを活用したGraphQL APIのユニットテストと、ユニットテストをAmplify Consoleでのデプロイ時に実行するところまでをご紹介しました。日々の開発にお役立ていただけましたら幸いです！！！


## 参考資料
* [Effective AppSync 〜 Serverless Framework を使用した AppSync の実践的な開発方法とテスト戦略 〜](https://qiita.com/G-awa/items/095faa9a94da09bc3ed5)
* [Jestで非同期関数が例外を投げることをテストする。](https://qiita.com/sosukesuzuki/items/e055fc3956556077fe9f)
* [Getting Started · Jest](https://jestjs.io/docs/ja/getting-started)
* [この頃流行りのJestを導入して軽快にJSをテストしよう - Qiita](https://qiita.com/hogesuke_1/items/8da7b63ff1d420b4253f)
* [graphql-request - npm](https://www.npmjs.com/package/graphql-request)
* [JSON Web Tokens - jwt.io](https://jwt.io/)
* [base64url - npm](https://www.npmjs.com/package/base64url)
* [Crypto | Node.js v15.3.0 Documentation](https://nodejs.org/api/crypto.html#crypto_class_hmac)