Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

Postman, Newman で始める E2E テスト

f:id:techtouch:20200721095821j:plain

バックエンドエンジニアの misu です。最近はブンブンチョッパーでチャーハンやドライカレーばっかり作ってます。

この記事について

弊社では REST API 定義置き場やクライアントとして機能する Postman を使っています。Postman は、登録してある定義に基づいて API リクエストを投げる Newman というライブラリが提供されており、E2E テストのセットアップが簡単にできます。今回は、これらのライブラリを使って E2E 環境を Github Actions 上に作ってみたので簡単なサンプルと一緒に使用感を見ていただけたらと思います。

内容

Postman と Newman について

Postman

冒頭で説明したとおり、API 定義を管理したり、API クライアントとして使うことができるデスクトップアプリケーションです(ブラウザ上からも使えます)。他には Example となる API レスポンスを定義しておけば、その値を返すモックサーバとして使うこともできます。フロントエンドの方と連携して仕事をするときには便利です。X-Api-Key をヘッダにつけて送らなければリクエストエラーになるようにもできて、セキュリティ面の考慮も入っています。

Newman

Postman 用のコマンドラインコレクションランナーです。Postman コレクションをコマンドラインから直接実行してテストすることができます。拡張性を念頭に置いて作られているので、継続的な統合サーバやビルドシステムと簡単に統合できます。Postman コレクションとは、Postman に登録された API 定義の集合です。 実行の出力はこのようになります。 公式 より引用 f:id:techtouch:20200720225702g:plain

モチベーション

弊社のようなエンタープライズ領域のサービスは特に品質が求められるので、安心してデプロイしていける仕組みが必要だったからです。もちろん、単体テストなどでカバーしていますが、変更や新規開発ですべての API の振る舞いが担保されているかはわかりません。また、リファクタやバージョンアップをしたくても影響範囲が読めず手が回らなくなっている部分があり、その不安を解決したかったです。 E2E に Postman を利用したのは、既に API 定義がそれらで記述されており、運用が行われなくなったテストが一部あったからです。開発初期から Postman をエンジニアチーム全体で利用しています。

実行例

今回は Newman を実行できるサンプルリポジトリを作ったので、これをベースに見ていきたいと思います。 API service-aservice-a が使う DB(MySQL)を用意しました。

service-a は、Go で書きました。

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    "github.com/gorilla/mux"
    "github.com/smith-30/newman-example/rdb"
)

type User struct {
    ID   int
    Name string
}

func NewUser() User {
    return User{
        ID:   1,
        Name: "user",
    }
}

func ping(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "ping")
}

func createUser(w http.ResponseWriter, r *http.Request) {
    res, err := json.Marshal(NewUser())
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    w.Write(res)
}

func showUser(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)

    // postman の変数更新を確認するため意図的にこう書いてます
    if vars["id"] != "1" {
        http.Error(w, "invalid user id", http.StatusBadGateway)
        return
    }

    res, err := json.Marshal(NewUser())
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.Write(res)
}

func main() {
    _, err := rdb.Mysql("test", "test_pass_hoge", "mysql", "3306", "testdb", true)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%#v\n", "start service-a")

    r := mux.NewRouter()
    r.HandleFunc("/ping", ping)
    // ユーザ作成
    r.HandleFunc("/user", createUser).Methods("POST")
    // ユーザ詳細
    r.HandleFunc("/user/{id}", showUser).Methods("GET")

    log.Fatal(http.ListenAndServe(":8011", r))
}

公開している API はこちらで確認できます。Postman の機能で web に公開するのも簡単です。

テストの設定

Newman で実行していくテストは Javascript でこのように書けます。単純に200の status code が返ってくるか確認しています。(/ping の例)

f:id:techtouch:20200721092504p:plain

API定義の順番依存になってしまうのが難点ですが、ユーザ作成後の ID を Postman 上の変数に登録することで、ユーザ詳細の API をこんな風に検査していくことが可能です。ユーザ詳細の API を呼ぶために、ユーザ作成の API から返ってきたレスポンスをパースして、 user_id という変数に登録しています。

f:id:techtouch:20200721093045p:plain

そして、ユーザ詳細の API では user_id の変数を使ってリクエストをしています。

f:id:techtouch:20200721095753p:plain

こうすることで、id が変化しても E2E のときに値が合わなくて 404 が返ってくることがなくなります。個人的にこの変数の取り回しを簡単にできるのが嬉しいと思ってます。もちろん、共通の変数を環境変数として登録することもできます。上記では {{service_a_url}} がそれにあたります。

Github Actions にのせる

docker-compose で管理している開発環境を利用したいので、Github Actions 上で開発環境を立ち上げ、Newman による検査を行うようにしました。

version: "3.7"

services:
  service-a:
    build:
      context: .
      dockerfile: a/Dockerfile
    privileged: true
    entrypoint:
      - dockerize
      - -timeout
      - 300s
      - -wait
      - tcp://mysql:3306
    command: ./main
    ports:
      - "8011:8011"
    restart: always
    depends_on:
      - mysql

  mysql:
    image: bitnami/mysql:5.7.25
    volumes:
      - ./mysql/my.cnf:/opt/bitnami/mysql/conf/my_custom.cnf:ro
    environment:
      MYSQL_ROOT_PASSWORD: "test_r00t"
      MYSQL_DATABASE: testdb
      MYSQL_USER: test
      MYSQL_PASSWORD: test_pass_hoge
      BITNAMI_DEBUG: "true"

  newman:
    build:
      context: .
      dockerfile: e2e/Dockerfile
    entrypoint:
      - dockerize
      - -timeout
      - 300s
      - -wait
      - tcp://service-a:8011
    volumes:
      - ./e2e:/etc/newman/e2e

E2E 実行を安定化させるために dockerize を利用しています。コンテナが別のコンテナに依存している場合に、別のコンテナが立ち上がるまで実行を待たせることができます。Actions 上では、docker-compose up -d で立ち上げたあとに、Newman を docker-compose run で走らせるのですが、service-a が mysql の立ち上げを待たないといけません。docker-compose の起動結果次第では、service-a が立ち上がっておらず、そもそもテストが正常に実行できないことがあるので工夫しました。

Actoins の設定はシンプルに書いてあります。

# This is a basic workflow to help you get started with Actions

name: Newman Example

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: docker-compose up -d
      - name: Run E2E
        env:
          POSTMAN_API_KEY: ${{ secrets.POSTMAN_API_KEY }}
        run: make run-e2e

make run-e2e を実行するための Makefile はこちら

run-e2e:
    curl -H 'X-Api-Key:$(POSTMAN_API_KEY)' https://api.getpostman.com/collections/625522-4e36bf20-943e-436b-a11f-9931275992ee > e2e/api.json
    curl -H 'X-Api-Key:$(POSTMAN_API_KEY)' https://api.getpostman.com/environments/625522-5469f08b-97de-4721-99c6-fbc21c782338 > e2e/e2e.postman_environment.json
    docker-compose run newman newman run e2e/api.json --environment=e2e/e2e.postman_environment.json

常に最新の API 定義と環境変数の json が取れるように Postman から取得するようにしています。そして、Newman ではこれらの設定を使って、API リクエストを実行して検査を行います。 実際の実行結果はこのようになります。

curl -H 'X-Api-Key:***' https://api.getpostman.com/collections/625522-4e36bf20-943e-436b-a11f-9931275992ee > e2e/api.json
// 出力は省略
curl -H 'X-Api-Key:***' https://api.getpostman.com/environments/625522-5469f08b-97de-4721-99c6-fbc21c782338 > e2e/e2e.postman_environment.json
// 出力は省略
docker-compose run newman newman run e2e/api.json --environment=e2e/e2e.postman_environment.json
2020/07/21 04:17:54 Waiting for: tcp://service-a:8011
2020/07/21 04:17:54 Problem with dial: dial tcp 172.18.0.4:8011: connect: connection refused. Sleeping 1s
2020/07/21 04:17:55 Problem with dial: dial tcp 172.18.0.4:8011: connect: connection refused. Sleeping 1s
2020/07/21 04:17:56 Connected to tcp://service-a:8011
newman

APIs

❏ service_a
↳ /ping
  GET http://service-a:8011/ping [200 OK, 120B, 91ms]
  ✓  Exp Status code is 200

↳ /user
  POST http://service-a:8011/user [201 Created, 135B, 6ms]
  ✓  Exp Status code is 201

↳ /user/{{user_id}}
  GET http://service-a:8011/user/1 [200 OK, 130B, 5ms]
  ✓  Exp Status code is 200

┌─────────────────────────┬──────────────────┬──────────────────┐
│                         │         executed │           failed │
├─────────────────────────┼──────────────────┼──────────────────┤
│              iterations │                1 │                0 │
├─────────────────────────┼──────────────────┼──────────────────┤
│                requests │                3 │                0 │
├─────────────────────────┼──────────────────┼──────────────────┤
│            test-scripts │                3 │                0 │
├─────────────────────────┼──────────────────┼──────────────────┤
│      prerequest-scripts │                0 │                0 │
├─────────────────────────┼──────────────────┼──────────────────┤
│              assertions │                3 │                0 │
├─────────────────────────┴──────────────────┴──────────────────┤
│ total run duration: 183ms                                     │
├───────────────────────────────────────────────────────────────┤
│ total data received: 48B (approx)                             │
├───────────────────────────────────────────────────────────────┤
│ average response time: 34ms [min: 5ms, max: 91ms, s.d.: 40ms] │
└───────────────────────────────────────────────────────────────┘
2020/07/21 04:17:57 Command finished successfully.

dockerize で Newman の実行が待たされているのも確認できます。

docker-compose run newman newman run e2e/api.json --environment=e2e/e2e.postman_environment.json
2020/07/21 04:17:54 Waiting for: tcp://service-a:8011
2020/07/21 04:17:54 Problem with dial: dial tcp 172.18.0.4:8011: connect: connection refused. Sleeping 1s
2020/07/21 04:17:55 Problem with dial: dial tcp 172.18.0.4:8011: connect: connection refused. Sleeping 1s
2020/07/21 04:17:56 Connected to tcp://service-a:8011

E2Eテストを陳腐化させないために

実際に E2E が動いても、継続してメンテしていかなければこういったテストはすぐに運用から忘れ去られ陳腐化してしまいます。たとえば、API 開発時に Postman に定義を載せ忘れ、レビューのときにも気づかなければ E2E を通らないテストが簡単にできあがります。ここは自動化で気づきたいところです。

弊社では、Postman と API Gateway に登録されている API が同一になるようにしています。API Gateway に Kong を利用しており、その下にマイクロサービスの API 群が存在しています。Kong では、登録されている API のリストを routes と呼ばれる API を使うことにより取得できます。この取得結果と Postman に登録されている API 定義に差異が出ないようにその差分確認のテストも CI に乗せることにしました。 また、API 定義があってもテストスクリプトが書かれてないと安心できないので、Postman のコレクション定義を取得したあと、API 定義にテストが書かれていなかったら CI で落ちるようにもしています。

その他

Q. Postman の定義のバックアップはどうしている?
A. github に変更差分を投げるようにしています。しかし、branch を分けたり、バージョニングができてないのでこの辺りは今後の課題にしていきたいです。

Q.ファイルアップロード周りのテストもできる?
A. コレクション登録時はローカルマシンのフルパスが入ってしまうので、curl でコレクションの定義取得後に sed でパスを書き換えています。書き換え先は、docker で newman のコンテナとマウントしているフォルダにしています。こうすれば、newman 実行時に newman がアップロードするファイルがないというエラーを出力することもありません。

参考