こんにちは。テクノロジー戦略本部 開発3部の今井です。
私は現在、顧客対応・SFAシステム(以下、CRM)のバックエンド・インフラ領域の開発に携わっています。
本記事では、品質向上の一環として取り組んでいるAPIのE2Eテストについてご紹介します。
CRMの背景・目的
バイセルではDXの取り組みのひとつとして、さらに多様な買取・販売チャネルに対応し、買取から販売まで一気通貫してデータを管理・活用する「バイセルリユースプラットフォーム Cosmos」(以下、Cosmos)の開発を進めています。
その中で私が携わっているプロジェクトでは、インサイドセールス部門で使用するCRMを開発しています。
主力事業である出張訪問買取の最初の入口となり、将来的にはマーケティング部やセールスコンプライアンス部等顧客対応のハブとなるシステムを目指しています。
CRMのバックエンド開発におけるテストと課題
CRMのバックエンド開発では、成果物の品質向上や実装効率の向上などを目的として、開発前にDesign Docの作成とE2Eのテストケースを作成するプロセスを設定しています。
以前は、テストケースの作成にスプレッドシートを使用し、手動でテストしていました。
しかし、この方法ではテストケースが増えるほど実施コストがかさみ、スプレッドシートの数も増えるなど限界が来ると想像していたため、運用と並行してE2Eテストを効率的に実施できる環境の整備を進めていました。
CRMのアーキテクチャ
CRMのアーキテクチャでは、主に下記のような技術を利用しております(一部抜粋)
- フロントエンド
- ログインを前提としたSPAをNext.js + TypeScriptで構築
- バックエンド
- APIサーバーをGoで構築
- エンドポイントの設計にはOpenAPIを採用
- Auth0で発行されたAccess Tokenを認可処理に利用
- その他
- インフラはGoogle Cloudを採用
- 認証基盤にAuth0を採用
E2Eテストの要件の一つとして、実際に稼働している開発環境へ向けて実行できることがありました。
そのため、Auth0からAccess Tokenを取得し、実際のユーザーがログインして操作するのと同様の動きを再現することが前提になります。
APIのみで完結するE2Eテストを選択した理由
E2Eテストとはユーザのワークフロー(シナリオ)に即した一連の行動に対してのテストで、多くはユーザインタフェース(以下、UI)を通じた形でテストが行われます。
しかしながら、UIを介したE2Eテストは多くの不安定要素を含むため、度々予期せぬ問題を発生させます。
これにはブラウザの特性、タイミングの問題、突発的なポップアップなどが原因であり、これらはデバッグの時間を増加させ、テストの信頼性を低下させます。
これらの課題を解決するために、UIを介さずに、APIレベルでのE2Eテストに特化することを選択しました。
そうすることでスピードと安定性、メンテナンスの容易さなどが享受できると考えました。
参考:
runnを採用した理由
APIのE2Eテストを実行できるツールを検討していたところ、CRMにはrunnが最適だと判断しました。
runnの特徴としては、テストシナリオをYAML形式で記述できる点が挙げられます。
これは可読性が高く、フロントエンドエンジニアにも親しみやすいというメリットがあります。
CRMの開発プロセスでは、設計段階でE2Eテストケースを作成し、レビューを通じて仕様に合致しているか等を確認します。
このレビューには、機能に応じて、企画や要件定義を行うチームやフロントエンドチームも参加することがあります。
CRMのAPIサーバーはGoで構築されているため、当初の検討ではGoでテストケースを作成することも考えましたが、レビューを行うメンバーの中にはGoに不慣れな者もいるため、YAMLなどの言語でテストケースを記述できるツールの導入を検討しました。
また、テスト要件としては、複数のAPIを連鎖させてテストできることや、CI/CDパイプラインへの統合のしやすさも重視しました。
具体的には、runnは1つのバイナリで動作し、外部依存を最小限に抑えることができるため、既存のCI/CD環境に容易に統合できます。
これにより、テストの自動化がスムーズに行え、デプロイメントの効率を向上させることが可能です。
runnは、そういったCRMの状況にフィットするツールでした。
具体的なE2Eテストの実行
今回は、事前に用意したアカウント情報を利用して、Auth0からAccess Tokenを受け取り、その後のテストを実行するケースを例にご紹介します。
※記載しているコードは実際のコードではありません。ご了承ください。
事前準備
以下の情報を環境変数として定義します。
.env
# Auth0で作成したアプリケーション情報 AUTH0_DOMAIN AUTH0_CLIENT_ID AUTH0_AUTH0_AUDIENCE # 認可を要求するスコープ # https://auth0.com/docs/api/authentication#authorize SCOPE # 検証環境のAPIエンドポイント # ローカルで実行する際には`http://host.docker.internal:8080`を利用 API_ENDPOINT # E2Eテストで利用するアカウント情報 EMAIL PASSWORD
Dockerコンテナからrunnを実行できるようにします。
Dockerfile
FROM alpine:3.19 # Install required packages RUN apk update && apk add --no-cache \ coreutils \ chromium # Install runn ARG TARGETPLATFORM RUN case $TARGETPLATFORM in \ "linux/amd64") \ wget -O runn.apk https://github.com/k1LoW/runn/releases/download/v0.101.2/runn_0.101.2-1_amd64.apk;; \ "linux/arm64") \ wget -O runn.apk https://github.com/k1LoW/runn/releases/download/v0.101.2/runn_0.101.2-1_arm64.apk;; \ esac && \ apk add --no-cache --allow-untrusted runn.apk ENV SHELL=/bin/ash WORKDIR /runn
認証を行い、マスタデータ取得APIを実行するフロー
事前準備で記載した情報を利用して、Auth0からAccess Tokenを取得します。
その後、取得したAccess Tokenを利用して、マスタデータ取得APIを実行するフローになります。
大まかな流れとしては、以下のようなものです。
- Code Verifier、Code Challengeを生成する。
- 認可リクエスト時にClient ID、Code Challengeを渡し認可コードを得る。(ブラウザ上で実行される)
- 取得した認可コードとCode Verifierを渡し、Access Tokenを得る。
- 取得したAccess TokenをBearerヘッダーへ付与し、マスタデータ取得APIを実行する。
補足:
記載しているフローは、Auth0にてPKCEが設定されていることが前提になります。
authorize.yaml
desc: 'Auth0からAccess Tokenを取得する' runners: cc: chrome://new authReq: ${AUTH0_DOMAIN} vars: loginUrl: ${AUTH0_DOMAIN}/authorize clientId: ${AUTH0_CLIENT_ID} audience: ${AUTH0_AUDIENCE} scope: ${SCOPE} email: ${EMAIL} password: ${PASSWORD} redirectUri: 'http://localhost:3000' responseType: 'code' responseMode: 'query' codeChallengeMethod: 'S256' steps: generateVerifier: desc: 'verifierを生成' exec: command: echo -n $(head -c 32 /dev/random | basenc --base64url | sed 's/=//g') shell: ${SHELL} test: current.exit_code == 0 bind: verifier: current.stdout generateChallenge: desc: 'challengeを生成' exec: command: | if [[ uname == 'Darwin' ]]; then echo -n '{{ verifier }}' | sha256sum -z | xxd -r -ps | basenc --base64url | sed 's/=//g' else echo -n '{{ verifier }}' | sha256sum -z | xxd -rp | basenc --base64url | sed 's/=//g' fi shell: ${SHELL} test: current.exit_code == 0 bind: challenge: current.stdout generateAuthorizeUrl: desc: '認証URLを生成' exec: command: > echo '{{ vars.loginUrl }} ?client_id={{ vars.clientId }} &response_type={{ vars.responseType }} &scope={{ vars.scope }} &audience={{ vars.AUTH0_AUDIENCE }} &redirect_uri={{ vars.redirectUri }} &codeChallenge={{ challenge }} &codeChallengeMethod={{ vars.codeChallengeMethod }}' | sed 's/ //g' test: current.exit_code == 0 bind: authorizeUrl: current.stdout authorizeProcess: desc: 'ログイン画面を開く' cc: actions: - navigate: '{{ authorizeUrl }}' - wait: '1sec' - submit: 'button[data-provider="waad"]' - wait: '1sec' - sendKeys: sel: 'input[name="loginfmt"]' value: '{{ vars.email }}' - click: '#idSIButton9' - wait: '1sec' - sendKeys: sel: 'input[name="passwd"]' value: '{{ vars.password }}' - wait: '1sec' - submit: '#idSIButton9' - wait: '1sec' - location test: | current.url contains '?code=' bind: redirectUrl: current.url parseAuthorizeToken: desc: 'リダイレクトURLをパースして認可コードを取得' exec: command: echo -n "{{ redirectUrl }}" | awk -F'code=' '{print $2}' | awk -F'&' '{print $1}' | tr -d '\r\n' shell: ${SHELL} test: current.exit_code == 0 bind: code: current.stdout postAccessToken: desc: 'アクセストークンを取得' authReq: /oauth/token: post: headers: Content-Type: 'application/x-www-form-urlencoded' body: application/x-www-form-urlencoded: grant_type: 'authorization_code' client_id: '{{ vars.clientId }}' code: '{{ code }}' redirect_uri: '{{ vars.redirectUri }}' code_verifier: '{{ verifier }}' test: current.res.status == 200 dump: expr: current.res.body.access_token out: token
get_masters.yaml
desc: マスター取得APIを実行 runners: req: ${API_ENDPOINT} vars: accessToken: ${ACCESS_TOKEN} steps: - desc: "Authorizationヘッダ不足検証" req: /v1/call/masters: get: body: application/json: null test: | ( current.res.status == 400 && current.res.rawBody == "{\"error_type\":\"InvalidRequest\",\"title\":\"doesn't exist Authorization: Bearer xxxx in header\"}" ) - desc: "正常系検証" req: /v1/call/masters: get: headers: Authorization: "Bearer {{ vars.accessToken }}" body: application/json: null test: | current.res.status == 200
CRMでは、以下のようなヘルパースクリプトを用意して、ローカルから実行したり、各環境用のCIから環境変数を注入して実行するよう整備しています。
#!/bin/sh if [ -f .env ]; then export $(cat .env | xargs) else echo ".envファイルが見つかりません。" exit 1 fi docker build -t runn_docker . # 最初にAccess Tokenを取得する ACCESS_TOKEN=$(docker run --rm -v $(pwd)/runn:/runn \ -e AUTH0_DOMAIN=$AUTH0_DOMAIN \ -e AUTH0_CLIENT_ID=$AUTH0_CLIENT_ID \ -e AUTH0_AUDIENCE=$AUTH0_AUDIENCE \ -e SCOPE=$SCOPE \ -e EMAIL=$EMAIL \ -e PASSWORD=$PASSWORD \ runn_docker \ runn run authorize.yaml | grep -oP '(?<=access_token":")[^"]+') if [ -z "$ACCESS_TOKEN" ]; then echo "Access Tokenの取得に失敗しました。" exit 1 fi # 取得したAccess Tokenを利用し、テストを実行する docker run --rm -v $(pwd)/runn:/runn \ -e API_URL=$API_URL \ -e ACCESS_TOKEN=$ACCESS_TOKEN \ runn_docker \ runn run get_masters.yaml
参考:
最後に
runnを利用してまだ1ヶ月程度しか経過していませんが、CRMのバックエンド開発における課題も解消され、非常に重宝しています。
以前まで、テストケースが多い場合は1〜2時間かかっていたものもありましたが、今ではほとんどゼロになりました。
テストの実行時間が大幅に短縮されたことで、開発サイクルが迅速になり、リリースの頻度が増え、ユーザーへの新機能提供や機能改善のスピードも向上しています。
さらに、用意したテストケースは、そのまま負荷テストなどにも流用できるため、他の場面でも活躍しそうです。
バイセルではエンジニアを随時募集しております。興味のある方はぜひ以下の採用サイトをご覧ください。