バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

バイセルのCRMバックエンド開発におけるAPI E2Eテスト戦略

こんにちは。テクノロジー戦略本部 開発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テストに特化することを選択しました。
そうすることでスピードと安定性、メンテナンスの容易さなどが享受できると考えました。

参考:

martinfowler.com

runnを採用した理由

APIのE2Eテストを実行できるツールを検討していたところ、CRMにはrunnが最適だと判断しました。

github.com

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を実行するフローになります。

大まかな流れとしては、以下のようなものです。

  1. Code Verifier、Code Challengeを生成する。
  2. 認可リクエスト時にClient ID、Code Challengeを渡し認可コードを得る。(ブラウザ上で実行される)
  3. 取得した認可コードとCode Verifierを渡し、Access Tokenを得る。
  4. 取得した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

参考:

www.macnica.co.jp

zenn.dev

最後に

runnを利用してまだ1ヶ月程度しか経過していませんが、CRMのバックエンド開発における課題も解消され、非常に重宝しています。

以前まで、テストケースが多い場合は1〜2時間かかっていたものもありましたが、今ではほとんどゼロになりました。
テストの実行時間が大幅に短縮されたことで、開発サイクルが迅速になり、リリースの頻度が増え、ユーザーへの新機能提供や機能改善のスピードも向上しています。

さらに、用意したテストケースは、そのまま負荷テストなどにも流用できるため、他の場面でも活躍しそうです。

バイセルではエンジニアを随時募集しております。興味のある方はぜひ以下の採用サイトをご覧ください。

herp.careers

herp.careers