2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SaveData開発記録#23 Webモックテスト導入で 外部APIのテストを効率的に

2
Last updated at Posted at 2026-04-12
Page 1 of 8

はじめに

SimpleCovを導入してカバレッジを計測したところ、32.9% という現実を突きつけられました。

SimpleCovって何という方はこちら

 
SaveDataにはゲーム検索機能があり、以下の3つの外部APIと連携しています。

  • Twitch(IGDBのアクセストークン取得)
  • IGDB API(ゲーム検索)
  • DeepL API(日本語クエリを英語に翻訳)

テストを実行するたびに本物のAPIを叩いていると…

  • APIの制限にすぐ引っかかる
  • ネットが不安定だとテストが落ちる
  • 実行速度が遅くなる

そこで WebMock を導入して外部APIへのリクエストをモック化しました。

☆この記事でわかること☆

  • WebMockとは何か・なぜ使うのか
  • 複数の外部APIを同時にモック化する方法
  • .to_raise でエラーハンドリングのテストを書く方法

【ちょっと宣伝】こんなアプリを作っています。
ゲームx思い出を記録できるアプリ SaveData


WebMockとは?

テスト中に外部APIへのHTTPリクエストをブロックして、代わりに偽のレスポンスを返してくれるgemです。

Mockっていうのはモックアップの略で商品化前の試作品や模型のこと、
エンジニア業界でいう 
ダミーオブジェクト: テストや設計の段階で、
本物の機能を模倣するために用意される仮のシステムやコンポーネントを指します。

通常のテスト                     WebMockあり
RSpec → IGDB API(本物)        RSpec → WebMock(偽物)→ 即返答
         遅い・API制限あり                 速い・制限なし

本物のAPIが起動していなくてもテストが通るのが最大のメリットです。


導入

# Gemfile
group :test do
  gem 'webmock'
end
# spec/rails_helper.rb
require 'webmock/rspec'

# 外部APIへの実通信をブロック(localhostは許可)
WebMock.disable_net_connect!(allow_localhost: true)

IgdbServiceの中身(抜粋)

class IgdbService
  # ゲームを検索する(日本語・英語を自動判定)
  def self.search(query)
    token = get_token  # Twitchでトークン取得
    if contains_japanese?(query)
      results = search_by_alternative_names(query, token)
      english_query = translate_to_english(query)  # DeepLで翻訳
      results += search_games_directly(english_query, token) if english_query
      results.uniq { |g| g["id"] }
    else
      search_games_directly(query, token)
    end
  end

  private_class_method def self.get_token
    # Twitchからアクセストークンを取得(1時間キャッシュ)
    Rails.cache.fetch("igdb_token", expires_in: 1.hour) do
      res = Net::HTTP.post_form(URI("https://id.twitch.tv/oauth2/token"), ...)
      JSON.parse(res.body)["access_token"]
    end
  end

  private_class_method def self.translate_to_english(query)
    # DeepL APIで翻訳(失敗時はnilを返す)
    ...
  rescue => e
    nil
  end
end

テストコード一部抜粋

require 'rails_helper'

RSpec.describe IgdbService do
  before do
    # Twitchのトークン取得をモック化(全テスト共通)
    # IgdbService.searchを呼ぶと必ずここを通るのでモックが必須
    stub_request(:post, "https://id.twitch.tv/oauth2/token")
      .to_return(
        status: 200,
        body: { access_token: "fake_token", expires_in: 3600 }.to_json,
        headers: { 'Content-Type' => 'application/json' }
      )

    # 前のテストのトークンキャッシュが残らないようにクリア
    Rails.cache.clear
  end

  describe ".search" do
    context "英語で検索したとき" do
      before do
        stub_request(:post, "https://api.igdb.com/v4/games")
          .to_return(
            status: 200,
            body: [
              {
                "id" => 1,
                "name" => "Final Fantasy VII",
                "cover" => { "image_id" => "abc123" },
                "platforms" => [ { "name" => "PlayStation" } ],
                "genres" => [ { "name" => "RPG" } ]
              }
            ].to_json,
            headers: { 'Content-Type' => 'application/json' }
          )
      end

      it "ゲームが返ってくること" do
        results = IgdbService.search("Final Fantasy VII")
        expect(results).not_to be_empty
      end

      it "nameが含まれること" do
        results = IgdbService.search("Final Fantasy VII")
        expect(results.first["name"]).to eq("Final Fantasy VII")
      end

      it "cover_urlが付加されること" do
        results = IgdbService.search("Final Fantasy VII")
        expect(results.first["cover_url"]).to include("abc123")
      end
    end

    context "日本語で検索したとき" do
      before do
        # DeepL APIをモック化(日本語→英語翻訳)
        stub_request(:post, "https://api-free.deepl.com/v2/translate")
          .to_return(
            status: 200,
            body: {
              translations: [ { text: "Final Fantasy VII" } ]
            }.to_json,
            headers: { 'Content-Type' => 'application/json' }
          )

        # alternative_names検索をモック化(日本語タイトル検索)
        stub_request(:post, "https://api.igdb.com/v4/alternative_names")
          .to_return(
            status: 200,
            body: [
              {
                "name" => "ファイナルファンタジーVII",
                "game" => {
                  "id" => 1,
                  "name" => "Final Fantasy VII",
                  "cover" => { "image_id" => "abc123" }
                }
              }
            ].to_json,
            headers: { 'Content-Type' => 'application/json' }
          )

        # 英語翻訳後のIGDB検索もモック化
        stub_request(:post, "https://api.igdb.com/v4/games")
          .to_return(
            status: 200,
            body: [].to_json,
            headers: { 'Content-Type' => 'application/json' }
          )
      end

ポイントまとめ

① Twitchのトークン取得は全テスト共通でモック化

IGDBを使うとき必ずTwitchのトークン取得を通るため、一番外の before に書きます。
また Rails.cache.clear でテスト間のキャッシュ汚染を防ぎます。

② 日本語検索は3つ同時にモック化が必要

日本語で検索すると内部でDeepL・alternative_names・gamesの3つのAPIを叩くため、全部モック化しないとエラーになります。

.to_raise でエラーハンドリングもテストできる

stub_request(:post, "URL").to_raise(StandardError.new("connection failed"))

rescue ブロックの中まで通すことができます。カバレッジの赤い行を消すのに非常に有効です。

rescueっていうのは
rescue ブロックとは例外(エラー)が発生したときの安全装置。
DeepLが落ちていてもアプリが止まらないよう nil を返しています。
.to_raise でその安全装置が本当に動くかを確認できます。


まとめ

やったこと 結果
WebMock導入 外部APIなしでテストが通るようになった
Twitch・IGDB・DeepLをモック化 IGDBサービスのカバレッジが大幅アップ
エラーケースのテスト .to_raise でrescueブロックもカバー
カバレッジ 32.9% → 93%

やって良かったこと🌟

  • APIの制限を気にせずテストを何度でも実行できるようになった
  • エラーハンドリングが本当に機能しているか検証できた

最後まで読んでいただきありがとうございました🙏

こんなアプリを作っています。よければ触ってみてください!

ゲーム × 思い出を記録できるアプリ SaveData

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?