AWS Amplify + AWS AppSyncでUnitテスト書く時の How to

Posted on December 04, 2020  -  7 min read

概要

Amplify Framework + AWS AppSync でフロントエンドの Unit テスト書く時のハウツーをご紹介します。本記事ではアプリケーションの実装に React を用いていますが、基本的な考え方は Vue などの他のフレームワークでも同じです。

想定読者

  • Amplify 触ったことある
  • AppSync 触ったことある
  • jest を使った Unit テストを書いたことがある
  • React のソースが読める(と、尚良し)

Amplify、AppSync あまり触ったことない!という方は Amplify CLI GraphQL Transform とディレクティブで AppSync+DynamoDB をいじってみよう!(@model @auth, @key)の記事を参考にいただけると良いと思います!

Amplify で Unit テストを書く方法

API を Mock する

一般的にフロントエンドの Unit テストを書く場合は、外部の API 通信を Mock することが多いと思います。例えば以下のような axios を用いた API 通信を Mock する場合は、

// return { user: { name: 'taro', age: 22 }}
const user = await axios.get('/user/1').then((resp) => resp.data);
<User user={user} />;
jest.spyOn(axios, 'get').mockReturnValue(
  Promise.resolve({
  user: {
     name: 'taro',
     age: 22
  }
});

といった感じで、API 呼び出しを行う関数を Mock し、想定されるレスポンスに対し DOM が正しく描画されるか等のテストを行います。

Amplify で Unit テストを書く場合も基本的な考え方は同じです。Amplify Framework は AWS バックエンドとシームレスに連携できる API をたくさん提供しており、それらの API を Mock することで Unit テストを実装していきます。

例えば、S3 にファイルをアップロード処理を Mock する場合は

import { Storage } from 'aws-amplify';

const file = e.target.files[0];
// return { key: "<file_path>" }
Storage.put('example.png', file, {
  contentType: 'image/png',
})
  .then((result) => console.log(result))
  .catch((err) => console.log(err));
import { Storage } from 'aws-amplify';

jest.spyOn(Storage, 'put').mockReturnValue(
  Promise.resolve({
    key: 'example.png',
  })
);

この様に、想定されるレスポンスの通りに Mock するだけです。 他にも、よく使用する Mock の例いくつかあげます。

例 1. Cognito の認証済みユーザ情報を取得する

import { Auth } from 'aws-amplify';

Auth.currentAuthenticatedUser()
  .then((user) => {
    // ユーザ情報を用いた処理
    console.log(user);
  })
  .catch((err) => {
    console.log(err);
  });

Mock

import { Auth } from 'aws-amplify';

jest.spyOn(Auth, 'currentAuthenticatedUser').mockReturnValue(
  Promise.resolve({
    username: 'myuser',
    attributes: {
      email: 'test@test.com',
      email_verified: true,
      phone_number: '08012345678',
      // ...other attributes
    },
    // ...other parameters
  })
);

例 2. GraphQL を用いて AppSync からデータを fetch する

import { API, graphqlOperation } from 'aws-amplify';

API.graphql(graphqlOperation(`query ListTodos() {
  listTodos() {
    items {
      id
      name
    }
    nextToken
  }
}`).then(result => {
  // GraphQLのfetch結果の処理
  console.log(result)
}).catch(err => {
  console.log(err)
});

Mock

jest.spyOn(API, 'graphql').mockReturnValue(
  Promise.resolve({
    data: {
      listTodos: {
        items: [
          { id: 'todo1', name: 'todo_name_1' },
          { id: 'todo2', name: 'todo_name_2' },
        ],
      },
    },
  })
);

Amplify から AppSync の Unit テストを書く時に詰まるところ

さて、ここから本題である Amplify AppSync で Unit テストについて説明していきます。上の例で説明した様に基本的には関数(API.graphql)を Mock する方法で問題ありません。しかし、実際にテストを書いていこうとするといくつか問題が出てきます。

今回は、ボタンを押すと TODO が登録され、リアルタイムに新しい TODO が表示されるアプリケーションを例に見ていきましょう。

スクリーンショット 2019-12-18 19.50.28.png

問題 1. API.graphql を Mock するだけでは一つのテストで複数クエリが発行される場合に対処できない

例えば、コンポーネント作成時にデータの fetch と Subscribe を同時に行うケースなどです。

import React, { useEffect, useReducer } from 'react';
import { API, graphqlOperation } from 'aws-amplify';
import { listTodos } from './graphql/queries';
import { onCreateTodo } from './graphql/subscriptions';
import { createTodo } from './graphql/mutations';
import uuidV4 from 'uuid/v4';

const initialState = { todos: [] };

function reducer(state = initialState, action) {
  switch (action.type) {
    case 'SET_TODOS':
      return { todos: action.todos };
    case 'ADD_TODO':
      return { ...state, todos: [...state.todos, action.todo] };
    default:
      return state;
  }
}

const GraphQLApp = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  // (1) データをfetch
  useEffect(() => {
    getData();
  }, []);

  // (2) Subscriptionを開始
  useEffect(() => {
    const subscription = API.graphql(graphqlOperation(onCreateTodo)).subscribe({
      next: (payload) => {
        const todo = payload.value.data.onCreateTodo;
        dispatch({ type: 'ADD_TODO', todo });
      },
    });
    return () => subscription.unsubscribe();
  }, []);

  const getData = async () => {
    try {
      const result = await API.graphql(graphqlOperation(listTodos));
      dispatch({ type: 'SET_TODOS', todos: result.data.listTodos.items });
    } catch (err) {
      console.log('error fetching todos...', err);
    }
  };

  const clickButton = () => {
    const name = new Date() + '_TODO';
    API.graphql(
      graphqlOperation(createTodo, {
        input: {
          id: uuidV4(),
          name: name,
        },
      })
    );
  };

  return (
    <>
      <button className="create_button" onClick={clickButton}>
        CreateTodo
      </button>
      <ul>
        {state.todos.map((v, k) => {
          return (
            <li key={k} className="todo">
              No.{k + 1}{v.name}
            </li>
          );
        })}
      </ul>
    </>
  );
};
export default GraphQLApp;

このアプリケーションではコンポーネント作成時に 2 種類のクエリを発行しています。「(1) データの fetch」では Query を発行し、「(2) Subscription を開始」 では Subscription を発行しています。API.graphql の Mock には一つのパターンのレスポンスしか記述できないので、別の方法を検討する必要があります。一つのアイディアとして、API.graphql を Mock する代わりに、Model を作成し、この Model を Mock 化します。

import { API, graphqlOperation } from 'aws-amplify';
import { listTodos } from './graphql/queries';
import { createTodo } from './graphql/mutations';
import { onCreateTodo } from './graphql/subscriptions';
import uuidV4 from 'uuid/v4';

const TodoModel = {
  listTodos: () => {
    return API.graphql(graphqlOperation(listTodos));
  },

  createTodo: (name) => {
    return API.graphql(
      graphqlOperation(createTodo, {
        input: {
          id: uuidV4(),
          name: name,
        },
      })
    );
  },

  subscribeOnCreateTodos: () => {
    return API.graphql(graphqlOperation(onCreateTodo));
  },
};

export default TodoModel;

GrapQLApp.js からこの Mock を呼び出します。

import React, { useEffect, useReducer } from 'react';
import TodoModel from './TodoModel';

const initialState = { todos: [] };

function reducer(state = initialState, action) {
  switch (action.type) {
    case 'SET_TALKS':
      return { todos: action.todos };
    case 'ADD_TODO':
      return { ...state, todos: [...state.todos, action.todo] };
    default:
      return state;
  }
}

const GraphQLApp = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    getData();
  }, []);

  useEffect(() => {
    const subscription = TodoModel.subscribeOnCreateTodos().subscribe({
      next: (payload) => {
        const todo = payload.value.data.onCreateTodo;
        dispatch({ type: 'ADD_TODO', todo });
      },
    });
    return () => subscription.unsubscribe();
  }, []);

  const getData = async () => {
    try {
      const result = await TodoModel.listTodos();
      dispatch({ type: 'SET_TALKS', todos: result.data.listTodos.items });
    } catch (err) {
      console.log('error fetching todos...', err);
    }
  };

  const clickButton = () => {
    TodoModel.createTodo(new Date() + '_TODO');
  };

  return (
    <>
      <button className="create_button" onClick={clickButton}>
        CreateTodo
      </button>
      <ul>
        {state.todos.map((v, k) => {
          return (
            <li key={k} className="todo">
              No.{k + 1}{v.name}
            </li>
          );
        })}
      </ul>
    </>
  );
};
export default GraphQLApp;

Mock は以下のように記述できます。

// QueryのMock
jest.spyOn(TodoModel, 'listTodos').mockReturnValue(
  Promise.resolve({
    data: {
      listTodos: {
        items: [
          { id: 'todo1', name: 'todo_name_1' },
          { id: 'todo2', name: 'todo_name_2' },
        ],
      },
    },
  })
);

// SubscriptionのMock (Obervableについては後ほど解説)
let emitOnCreateTodos;
jest.spyOn(TodoModel, 'subscribeOnCreateTodos').mockReturnValue(
  new Observable((observer) => {
    emitOnCreateTodos = (v) => observer.next(v);
  })
);

このように API.graphql を Mock するのではなく、Model を Mock することで、一度に複数の API.graphql を呼び出す場合のテストにも対処することが可能です。

問題 2. Mutation トリガーの Subscription 処理をどのようにテストするか

GraphQLApp.js の例では、新しく TODO を作成(Mutation)したことをトリガーに Subscription が Mutation の更新を検知し、store の更新を行っています。

const subscription = TodoModel.subscribeOnCreateTodos().subscribe({
  // Mutationをトリガーにここ↓が呼び出される
  next: (payload) => {
    const todo = payload.value.data.onCreateTodo;
    dispatch({ type: 'ADD_TODO', todo });
  },
});

「Mutation をトリガーに正しく新規 TODO が表示されること」をテストするにはどのようにすれば良いでしょうか?API は Mock されているため、実際に AppSync の Subscription、Mutation を呼び出すわけにはいきません。 「問題 1」でも少し触れましたが、Subscription の Mock は以下のように行っています。

let emitOnCreateTodos;
jest.spyOn(TodoModel, 'subscribeOnCreateTodos').mockReturnValue(
  new Observable((observer) => {
    emitOnCreateTodos = (v) => observer.next(v);
  })
);
subscribeOnCreateTodos: () => {
  return API.graphql(graphqlOperation(onCreateTodo));
};

API.graphql(graphqlOperation(onCreateTodo))は Subscription クエリを発行する場合、Observable クラスを返却しています。結論から言うと、この Mock で返却している Observable を発火させれば、Mutation をトリガーに store を更新することができる処理をテストできます。

テストコードは以下のようになります。

it('test subscription', async () => {
  await act(async () => {
    wrapper = mount(<GraphQLApp />, { attachTo: container });
    await waitForExpect(() => {
      wrapper.update();
      // (1) Mutation発火前の画面に表示されているTODOの数をカウント
      const initialRenderdTodoLength = wrapper.find('.todo').length;

      // (2) Observableを発火(擬似的にSubscriptionがAppSyncからメッセージを受け取ったことを再現)
      emitOnCreateTodos({
        value: {
          data: {
            onCreateTodo: {
              id: 'todo3',
              name: 'todo_name_3',
            },
          },
        },
      });
      wrapper.update();
      // (3) 画面に表示されているTODOのカウントが1つ増えていることを確認
      expect(wrapper.find('.todo').length).toBe(initialRenderdTodoLength + 1);
    });
  });
});

これで、next: (payload)に emitOnCreateTodos に渡したデータを payload で受け取ることができます。 Query/Mutation/Subscription の全体のテストコードは以下になります。

import MyTest from './MyTest';
import waitForExpect from 'wait-for-expect';
import { act } from 'react-dom/test-utils';
import GraphQLApp from './GraphQLApp';
import Observable from 'zen-observable';

jest.spyOn(TodoModel, 'listTodos').mockReturnValue(
  Promise.resolve({
    data: {
      listTodos: {
        items: [
          { id: 'todo1', name: 'todo_name_1' },
          { id: 'todo2', name: 'todo_name_2' },
        ],
      },
    },
  })
);
jest.spyOn(TodoModel, 'createTodo').mockReturnValue(
  Promise.resolve({
    data: {
      createTodo: {
        id: 'todo3',
        name: 'todo_name_3',
      },
    },
  })
);
let emitOnCreateTodos;
jest.spyOn(TodoModel, 'subscribeOnCreateTodos').mockReturnValue(
  new Observable((observer) => {
    emitOnCreateTodos = (v) => observer.next(v);
  })
);
let wrapper;
let container;

it('test query', async () => {
  await act(async () => {
    wrapper = mount(<GraphQLApp />, { attachTo: container });
    await waitForExpect(() => {
      wrapper.update();
      // Queryで取得したTODOデータが2件表示されていること
      expect(wrapper.find('.todo').length).toBe(2);
    });
  });
});

it('test mutation', async () => {
  await act(async () => {
    wrapper = mount(<GraphQLApp />, { attachTo: container });
    await waitForExpect(() => {
      wrapper.update();
      // Create Todoボタンをクリック
      wrapper.find('.create_button').first().simulate('click');
      // Mutationが発行されたこと(対象の関数が呼び出されたこと)
      expect(TodoModel.createTodo.mock.calls.length).toBe(1);
    });
  });
});

it('test subscription', async () => {
  await act(async () => {
    wrapper = mount(<GraphQLApp />, { attachTo: container });
    await waitForExpect(() => {
      wrapper.update();
      // (1) Mutation発火前の画面に表示されているTODOの数をカウント
      const initialRenderdTodoLength = wrapper.find('.todo').length;

      // (2) Observableを発火(擬似的にSubscriptionがAppSyncからメッセージを受け取ったことを再現)
      emitOnCreateTodos({
        value: {
          data: {
            onCreateTodo: {
              id: 'todo3',
              name: 'todo_name_3',
            },
          },
        },
      });
      wrapper.update();
      // (3) 画面に表示されているTODOのカウントが1つ増えていることを確認
      expect(wrapper.find('.todo').length).toBe(initialRenderdTodoLength + 1);
    });
  });
});

テストを実行してみます

yarn test

無事全てのテストがパスしました!

スクリーンショット 2019-12-08 21.04.24.png