Googleでログインによる認証ありのREST APIにおいて、APIテストの自動化をやってみた

## はじめに

ブラウザ上での認証後にAPIを呼び出すために必要なトークンを取得でき、そのトークンをAuthorizationヘッダーのBearerに指定してAPIを呼び出す必要のあるAPIのテストを自動化する、というのを今回はやってみたいと思う。ブラウザ上で操作を行い認証を突破する必要があるため、[Cypress](https://www.cypress.io/)を利用する。

以下、APIテストの自動化のStepになる。

1. Cypressのセットアップ
1. Linux環境の設定
1. 認証を突破するためのCypressスクリプトの実装
1. VitestでのREST APIのテストの実装

※検証ということでトークンの取得部分はかなり簡素になっている。

※APIテストではあるが、今回のAPIテストはE2E（エンド・ツー・エンド）のテストのようなAPIテストと言えるだろう。

ソースコード全体は以下。

https://github.com/yutak23/serverless-express-vue-app/commit/77b89113fb64b4fafe1bc347eeae6a1dc7e137d4

## 1. Cypressのセットアップ
### インストールとプロジェクトの設定

[公式の手順](https://docs.cypress.io/guides/getting-started/installing-cypress#Installing)に則ればいい。注意として、[System requirements](https://docs.cypress.io/guides/getting-started/installing-cypress#System-requirements)を満たす必要があるので、そちらの対応も行う。

その後は、[Opening the App](https://docs.cypress.io/guides/getting-started/opening-the-app)に書かれている通り、`npx cypress open`コマンドでCypressをプロジェクトに追加する上での設定を行う。

※私は、Windows10上にVirtualBoxをインストールし、そのVirtualBoxでCentOSを立ち上げて開発をしていたため、自動でWindows側のブラウザが開かなかった。そのためコマンド実行時に出力されたInspect用のURLを自分でChromeに張り付けてセットアップを行った。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/d1f51989-a696-47b1-f19e-1c7d0f2ad8c8.png)

具体的な手順は以下。

1. Inspect用のエンドポイントをコピペしてChromeに張り付ける
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/0cb84e83-1976-151f-2862-27e4951e30ff.png)
2. `Cypress`をクリックすると、InspectのUIが開き、その中でCypressの設定画面が立ち上がるので`Continue >`をクリックする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/aa8df663-df9c-3d6d-5831-67b4b61c40f3.png)
3. `Configuration files`のページに遷移し、さらに`Continue`をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/ed0054d9-5da0-8a87-b9cb-7e41aec66c64.png)
4. 以下の画面が開くので初期設定は完了になる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/08ee8d4e-2ebe-a510-efc7-3ad0da4a9d2d.png)

※本来、最後のページの`Start E2E Testing in Electron`をクリックすると、[Add a test file](https://docs.cypress.io/guides/end-to-end-testing/writing-your-first-end-to-end-test#Add-a-test-file)にあるような画面が開くはず（と思っている）が、私の環境では画面が遷移しなかった。仕方ないので、サンプルなどを`./cypress`ディレクトリ内にspecファイルを作成したりした。

### ESLintの設定

上記でテストを書いていく前の基本的な設定はOKだが、ESLintくらいは設定が必要だと思うのでESLintの設定も先に済ませておく。
[Development Tools](https://docs.cypress.io/plugins#Development%20Tools)に載っている、[eslint-plugin-cypress](https://github.com/cypress-io/eslint-plugin-cypress)を利用する。設定は特にこだわりがなければextendsに`'plugin:cypress/recommended'`を記載するだけでいいだろう。

※私の環境ではVitestの設定もしており、VitestのESLint（[eslint-plugin-vitest](https://github.com/veritem/eslint-plugin-vitest)）と併用すると以下のように意図せずESLintのエラーが表示されてしまった。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/947541ac-e14f-88e0-4484-e9093913c78b.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/71e77cf3-dba0-0380-0ab0-ba94f8e31c97.png)

こうした場合には、[How do overrides work?](https://eslint.org/docs/latest/use/configure/configuration-files#how-do-overrides-work)に書かれている`overrides`の設定を行えばいい。具体的には以下のようにする事で、VitestのESLint pluginが有効になるファイルやCypressのESLint pluginが有効になるファイルを指定する事ができるので、Vitest側のルールでエラーになるという状態を解決できる。
```js:.eslintrc.cjs
...
module.exports = {
	...
	extends: [
		'plugin:vue/vue3-essential',
		'plugin:vuetify/recommended',
		'eslint:recommended',
		'airbnb-base',
		'@vue/eslint-config-prettier'
	],
	...
	overrides: [
		{ files: ['__tests__/**/*.js'], extends: ['plugin:vitest/recommended'] },
		{ files: ['cypress/**/*.js'], extends: ['plugin:cypress/recommended'] }
	],
	...
};
```

### 試しに[Write a real test](https://docs.cypress.io/guides/end-to-end-testing/writing-your-first-end-to-end-test#Write-a-real-test)に書かれているテストがsuccessするか？検証する

実装自体は[Write a real test](https://docs.cypress.io/guides/end-to-end-testing/writing-your-first-end-to-end-test#Write-a-real-test)に書かれているまんま。実際にテストを流すと以下のようにsuccessになる事が確認できた。
```js:cypress/e2e/first-test.spec.js
describe('My First Test', () => {
	it('Gets, types and asserts', () => {
		cy.visit('https://example.cypress.io');

		cy.contains('type').click();

		// Should be on a new URL which
		// includes '/commands/actions'
		cy.url().should('include', '/commands/actions');

		// Get an input, type into it
		cy.get('.action-email').type('fake@email.com');

		//  Verify that the value has been updated
		cy.get('.action-email').should('have.value', 'fake@email.com');
	});
});

```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/6c1bdcee-88a0-6dc9-b7e3-d988910dbc2e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/746e75d1-abd8-744b-90cf-f1e1305abefc.png)

これでCypressのセットアップは完了になる。

## 2. Linux環境の設定

今回はGooleでのログイン（OpenID Connect）による認証を行うため、ドメインをの設定が必要になる（詳細は[過去の記事](https://qiita.com/yuta-katayama-23/items/8136518c95a2933ad1e4#3-express%E3%81%ABexpress-session%E3%82%92%E5%B0%8E%E5%85%A5%E3%81%97%E3%83%AD%E3%82%B0%E3%82%A4%E3%83%B3%E3%82%BB%E3%83%83%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E7%AE%A1%E7%90%86%E3%81%A7%E3%81%8D%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B)を参照）。

という事で、Linux（CentOS）の設定を行う。WindowsでもLinuxでもhostsファイルでなんちゃってDNSを実現できるので、`/etc/hosts`を以下のように更新する。

```:/etc/hosts
127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4
::1         localhost localhost.localdomain localhost6 localhost6.localdomain6
192.168.56.2   customlocalhost example.com
```

上記のように設定する事で、`example.com`へのアクセスは`192.168.56.2`にプロキシされる＝ドメインが解決される（今回、Googleでのログインを行うが、GoogleのOpenID Connect（OAuth2.0）のクライアントuriはhttps必須でかつドメインである必要があるのでこの対応が必要になる）。

## 3. 認証を突破するためのCypressスクリプトの実装（Cypressでの自動ログイン）

ここからは「Googleでログイン」による認証を突破するための実装を行っていく。実装は以下のようになった。
```js:cypress/e2e/login-and-get-token.spec.js
/* eslint-disable cypress/no-unnecessary-waiting */
describe('Google login and get token', () => {
	it('login and get token', () => {
		cy.visit('https://example.com');

		cy.origin('https://accounts.google.com/', () => {
			// eslint-disable-next-line no-unused-vars
			Cypress.on('uncaught:exception', (err) => false);
			cy.get('#identifierId').type(Cypress.env('GOOGLE_ACCOUNT_ID'));
			cy.get('button[jsname="LgbsSe"] span.VfPpkd-vQzf8d')
				.contains('次へ')
				.click();

			cy.wait(5000);

			cy.get('#password input[type="password"]').type(
				Cypress.env('GOOGLE_ACCOUNT_PASSWORD')
			);
			cy.get('button[jsname="LgbsSe"] span.VfPpkd-vQzf8d')
				.contains('次へ')
				.click();
		});

		cy.wait(10000);

		cy.request('https://example.com/auth/token').as('authTokenRequest');
		cy.get('@authTokenRequest').then((response) => {
			expect(response.status).to.eq(200);
			expect(response.body.token).to.not.eq(null);
			cy.writeFile('__tests__/token.json', { token: response.body.token });
		});
	});
});
```
```js:cypress.config.js
import 'dotenv/config.js';
import { defineConfig } from 'cypress';

export default defineConfig({
	pageLoadTimeout: 70000,
	defaultCommandTimeout: 65000,
	env: {
		GOOGLE_ACCOUNT_ID: process.env.GOOGLE_ACCOUNT_ID,
		GOOGLE_ACCOUNT_PASSWORD: process.env.GOOGLE_ACCOUNT_PASSWORD
	},
	e2e: {
		supportFile: 'cypress/support/e2e.js',
		experimentalModifyObstructiveThirdPartyCode: true,
		specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,ts}'
	}
});
```

ユーザーIDとパスワードは環境変数で渡すようにしている。テストで利用するtokenはファイルに書き出して保存する事で、APIテスト時に利用できるようにする（このtokenを取得する事がここでやりたい事）。
今回は簡易的な実装なので、ログインセッションを持つユーザーであれば`/auth/token`に対しGETリクエストをする事でトークンを取得できるという設計になっている。

実装上の補足を少しする。

- cy.origin()
[origin](https://docs.cypress.io/api/commands/origin)は、一度のテストで異なる複数のドメインを跨ぐ場合に利用するコマンドで、今回はGoogleでログインを行うために、example.comとGoogleのドメインに行き来するのでcy.origin()を利用している

- experimentalModifyObstructiveThirdPartyCode: true
サードパーティの.jsまたは.htmlファイル内のフレームバスティングでよく見られるパターンに一致するコードを置き換えるための設定。これがないとGoogleのログイン画面に遷移する際にエラーになる。

実装したコードを実行すると、少し時間はかかるが最終的に`__tests__/token.json`にファイルが吐き出され、意図通り動いている事が確認できた。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/0c8b56ca-e1f8-b450-61d6-431b34ea60bf.png)

※`uncaught:exception`のエラーが発生する事がままあるが、これは[Uncaught Exceptions](https://docs.cypress.io/api/cypress-api/catalog-of-events#Uncaught-Exceptions)に書かれている方法で敢えて無視するという事もできる（エラー全文は以下）。
```:エラーの詳細
     Error: The following error originated from your application code, not from Cypress.

  > ResizeObserver loop limit exceeded

When Cypress detects uncaught errors originating from your application it will automatically fail the current test.

This behavior is configurable, and you can choose to turn this off by listening to the `uncaught:exception` event.

https://on.cypress.io/uncaught-exception-from-application
```
```js:cypress/support/e2e.js
Cypress.on('uncaught:exception', (err, runnable) => {
	if (err.message.includes('ResizeObserver loop limit exceeded')) return false;
	return true;
});
```

※`cy.origin()`は別ドメインでの操作扱いなので、`cypress/support/e2e.js`に上記ののような実装をしていても有効にならない（詳細は[Cypress uncaught:exception handler not working with Magic.link flow](https://stackoverflow.com/questions/75032102/cypress-uncaughtexception-handler-not-working-with-magic-link-flow)を参照）。そのため、`cy.origin()`内に独自に`uncaught:exception`に対する設定が必要になる。

```js
		cy.origin('https://accounts.google.com/', () => {
			// eslint-disable-next-line no-unused-vars
			Cypress.on('uncaught:exception', (err) => false); // <- ここに設定する必要がある
			cy.get('#identifierId').type('hogehoge');
			cy.get('button[jsname="LgbsSe"] span.VfPpkd-vQzf8d')
				.contains('次へ')
				.click();

			cy.wait(20000);

			cy.get('#password input[type="password"]').type('passw0rd');
			cy.get('button[jsname="LgbsSe"] span.VfPpkd-vQzf8d')
				.contains('次へ')
				.click();
		});
```

## 4. VitestでのREST APIのテストの実装

最後にREST APIのテストを実装してみる。

Cypressによる認証突破により`__tests__/token.json`にアクセストークンが書かれたファイルができるので、それを読み取ってREST APIを叩く感じになる。実装としては以下のようにしてみた。

```js:
import snakecaseKeys from 'snakecase-keys';
import { describe, expect, test, beforeEach } from 'vitest';
import axios from 'axios';
import fs from 'fs';
import config from 'config';
import appRoot from 'app-root-path';

const accessTokens = {};
const setAuthorization = (user) => {
	axios.defaults.headers.common.Authorization = `Bearer ${accessTokens[user].token}`;
};

describe('Setup token', () => {
	test('import token.json', async () => {
		const tokenJson = JSON.parse(
			fs.readFileSync(appRoot.resolve('__tests__/token.json'), 'utf8')
		);
		expect(tokenJson).toHaveProperty('token', expect.any(String));
		accessTokens.testUser = { token: tokenJson.token };
	});
});

describe('normal POST todo', () => {
	const host = config.get('host');
	const data = snakecaseKeys({
		accountType: 'personal',
		lastName: '太郎',
		firstName: '山田',
		gender: 'male'
	});

	beforeEach(() => {
		setAuthorization('testUser');
	});

	describe.skip('Setup', () => {});

	describe('Test Block', () => {
		test('should return 201', async () => {
			const res = await axios.post(`${host}/api/v1/signup`, data);
			expect(res.status).toBe(201);
			expect(res.data).not.toHaveProperty('id');
			expect(res.data).toHaveProperty('account_type', 'personal');
			expect(res.data).toHaveProperty('last_name', '太郎');
			expect(res.data).toHaveProperty('first_name', '山田');
			expect(res.data).toHaveProperty('full_name', '山田 太郎');
			expect(res.data).toHaveProperty('gender', 'male');
			expect(res.data).toHaveProperty('email', expect.any(String));
			expect(res.data).toHaveProperty('last_logined_at', expect.any(Number));
			expect(res.data).toHaveProperty('created_at', expect.any(Number));
			expect(res.data).toHaveProperty('updated_at', expect.any(Number));
		});

		test('should return 409', async () => {
			await expect(() =>
				axios.post(`${host}/api/v1/signup`, data)
			).rejects.toThrowError('Request failed with status code 409');
		});

		test('should return 409 by try-catch', async () => {
			let res;

			try {
				res = await axios.post(`${host}/api/v1/signup`, data);
			} catch (error) {
				res = error.response;
			}

			expect(res.status).toBe(409);
			expect(res.data).toHaveProperty('message', 'user already exists');
			expect(res.data).toHaveProperty(
				'errors[0].message',
				'user already exists'
			);
			expect(res.data).toHaveProperty('status_code', 409);
			expect(res.data).toHaveProperty('code', expect.any(String));
			expect(res.data).toHaveProperty('path', 'POST:/api/v1/signup');
		});
	});

	describe('Terndown', () => {
		test('delete user: DELETE:/api/v1/user', async () => {
			const res = await axios.delete(`${host}/api/v1/user`);
			expect(res.status).toBe(204);
		});
	});
});
```

APIを呼び出して、そのレスポンスをチェックする事でAPIテストとしている。

上記のコードを実行してみると、以下のようにAPIテストが自動で実行できるようになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/ea24d340-b854-f588-c54a-383653a118e5.png)

※`setAuthorization()`を`beforeEach()`で都度実行する事で、AuthorizationヘッダーのBearerを暗黙的に設定するようにしている。

## まとめとして

今回はREST APIの自動テストを、Cypressによる認証突破からやってみた。よくありがちなアプリケーションの実装（Googleなどでログイン後にアプリ内にセッションができ、そのセッションを使ってAPIを呼び出せる）において、APIテストを自動化する際には、今回やったようなアプローチが使えるかもしれない。

※ただし、今回はdockerで立てたNginxでプロキシをしたり、`/etc/hosts/`のなんちゃってDNSを利用したりと、CI環境で同じようにAPIテストを実施するには特にネットワークの部分でいくつか課題があるだろう。今回の方法を利用したCIでのAPIテスト自動化はまた機会があればやってみたいと思う。