DockerとSeleniumで構築するE2Eテスト環境

はじめに

こんにちは、テクノロジー本部の田中です。 本記事は、「エムティーアイ Blog Summer 2025」の8月8日分の記事となります。

今回は、以前に個人的興味があってやってみた話「Docker上の仮想環境でブラウザを動かしてみた」の続きものとして、DockerとSeleniumを組み合わせてE2Eテスト環境を構築する方法を書きたいと思います。

Docker環境でのSelenium E2Eテスト

なぜ、DockerでE2Eテスト環境を用意するのか?

Webアプリケーションの品質保証において、E2Eテストは欠かせない要素です。
Dokcerを使うメリットとして

  • どのマシンでも同じテスト環境が再現可能
  • 開発者間の共有が容易
  • テストに必要な全ての要素をコンテナに封じ込め

Firefoxブラウザの Docker 化

FirefoxのDocker化については、以前書いた記事「Docker上の仮想環境でブラウザを動かしてみた」で詳細に解説しています。 詳細な実装方法は上記記事をご参照ください。

前回からのアップデート

若干、アップデートをしました!
Firefoxのバージョン選択を可能にしています。(デフォルトは最新版)

Python Selenium テスト環境の構築

ここからが本記事の核心部分です。SeleniumとPytestを組み合わせたテスト環境をDockerで構築していきます。

今回のサンプルプロジェクト構造

まず、全体のプロジェクト構造を紹介します。
このプロジェクトのファイルのほとんどはAIを利用して、作成してもらいました!

project-root/
├── counter-app/                # テスト対象アプリケーション
│   ├── index.html              # メインHTMLファイル
│   ├── styles.css              # スタイルシート
│   ├── script.js               # JavaScriptロジック
│   ├── server.js               # Node.js開発サーバー
│   ├── package.json            # Node.js依存関係
│   ├── counter-app.Dockerfile  # アプリ用Dockerfile
│   └── README.md               # アプリ説明
├── python-e2e-tests/          # E2Eテストプロジェクト
│   ├── tests/                  # テストファイル
│   │   ├── base_test.py        # ベーステストクラス
│   │   ├── test_counter_basic.py # 基本機能テスト
│   │   ├── test_counter_performance.py # パフォーマンステスト
│   │   └── test_counter_ui.py  # UIテスト
│   ├── conftest.py             # pytest設定
│   ├── requirements.txt        # Python依存関係
│   ├── Dockerfile              # テスト実行用コンテナ
│   ├── docker-compose.yml      # 統合環境定義
│   ├── run_tests_docker.sh     # テスト実行スクリプト
│   ├── reports/                # テストレポート出力先
│   │   └── screenshots/        # 失敗時スクリーンショット
│   └── README.md               # テストプロジェクト説明

Dockerfile

まず、Python Selenium テスト実行用のDockerfileを作成します。

FROM python:3.11-slim

WORKDIR /app

# Firefox と Xvfb(ヘッドレス表示用)のインストール
RUN apt-get update && apt-get install -y \
    wget \
    curl \
    firefox-esr \
    xvfb \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# geckodriver のインストール
RUN GECKO_VERSION=$(curl -s https://api.github.com/repos/mozilla/geckodriver/releases/latest | grep -Po '"tag_name": "\K.*?(?=")') && \
    wget -O /tmp/geckodriver.tar.gz https://github.com/mozilla/geckodriver/releases/download/$GECKO_VERSION/geckodriver-$GECKO_VERSION-linux64.tar.gz && \
    tar -xzf /tmp/geckodriver.tar.gz -C /usr/local/bin && \
    chmod +x /usr/local/bin/geckodriver && \
    rm /tmp/geckodriver.tar.gz

# Python依存関係のインストール
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# 環境変数の設定
ENV PYTHONPATH=/app
ENV DISPLAY=:99

CMD ["./run_tests_docker.sh", "tests/", "-v", "--html=reports/report.html", "--self-contained-html"]

requirements.txt

selenium==4.15.2
pytest==7.4.3
pytest-html==4.1.1
webdriver-manager==4.0.1

pytest 設定(conftest.py)

import pytest
import os
import time

def pytest_addoption(parser):
    """コマンドラインオプションの追加"""
    parser.addoption(
        "--app-url", 
        action="store", 
        default=os.environ.get("APP_URL", "http://counter-app:3000"), 
        help="テスト対象アプリのURL"
    )

@pytest.fixture(scope="session")
def app_url(request):
    """アプリケーションのURL"""
    return request.config.getoption("--app-url")

@pytest.fixture(scope="function")
def screenshot_on_failure(request):
    """テスト失敗時のスクリーンショット撮影"""
    yield
    
    if hasattr(request.node, 'rep_call') and request.node.rep_call.failed:
        if hasattr(request.instance, 'driver'):
            screenshot_dir = "reports/screenshots"
            os.makedirs(screenshot_dir, exist_ok=True)
            
            screenshot_name = f"{request.node.name}_{int(time.time())}.png"
            screenshot_path = os.path.join(screenshot_dir, screenshot_name)
            
            try:
                request.instance.driver.save_screenshot(screenshot_path)
                print(f"スクリーンショット保存: {screenshot_path}")
            except Exception as e:
                print(f"スクリーンショット撮影失敗: {e}")

ベーステストクラス(base_test.py)

import pytest
from selenium import webdriver
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import time
import os

class BaseTest:
    """Docker Compose環境用のSeleniumテストのベースクラス"""
    
    @pytest.fixture(autouse=True)
    def setup_driver(self):
        """テスト実行前のSeleniumドライバー設定"""
        firefox_options = Options()
        firefox_options.add_argument("--no-sandbox")
        firefox_options.add_argument("--disable-dev-shm-usage")
        firefox_options.add_argument("--headless")  # Docker環境では常にヘッドレス
        
        # プロファイル設定
        firefox_options.set_preference("dom.webnotifications.enabled", False)
        firefox_options.set_preference("media.volume_scale", "0.0")
        
        try:
            self.driver = webdriver.Firefox(options=firefox_options)
            self.driver.set_window_size(1920, 1080)
            self.wait = WebDriverWait(self.driver, 10)
            
            # カウンターアプリのベースURL
            self.base_url = os.environ.get("APP_URL", "http://counter-app:3000")
            
            yield
            
        finally:
            if hasattr(self, 'driver'):
                self.driver.quit()
    
    def navigate_to_app(self):
        """カウンターアプリにナビゲート"""
        self.driver.get(self.base_url)
        self.wait.until(EC.presence_of_element_located((By.ID, "counter")))
    
    def get_counter_value(self):
        """現在のカウンター値を取得"""
        counter_element = self.driver.find_element(By.ID, "counter")
        return int(counter_element.text)
    
    def click_increase_button(self):
        """増加ボタンをクリック"""
        increase_btn = self.wait.until(EC.element_to_be_clickable((By.ID, "increaseBtn")))
        increase_btn.click()
    
    def click_decrease_button(self):
        """減少ボタンをクリック"""
        decrease_btn = self.wait.until(EC.element_to_be_clickable((By.ID, "decreaseBtn")))
        decrease_btn.click()

E2Eテストの実装例

対象アプリケーションの概要

今回テストするのは、シンプルなカウンターアプリケーションです。
マジでここは100%AIで作成しました...

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>カウンターアプリ</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div class="counter-container">
        <h1>カウンター</h1>
        <div id="counter" class="counter-display">0</div>
        <div class="button-container">
            <button id="decreaseBtn" class="decrease-btn">-</button>
            <button id="increaseBtn" class="increase-btn">+</button>
        </div>
    </div>
    <script src="script.js"></script>
</body>
</html>

実際の画面は以下のようなものです。

カウンターサンプルアプリ

カウンターアプリのテスト実装

基本機能テストの実装例

ベーステストクラスを継承して、具体的なテストケースを実装します。
ちなみにこれもAIです。

import pytest
from selenium.webdriver.common.by import By
from tests.base_test import BaseTest

class TestCounterBasic(BaseTest):
    """カウンターアプリの基本機能テスト"""
    
    def test_page_loads_correctly(self):
        """ページが正しく読み込まれることを確認"""
        self.navigate_to_app()
        
        # タイトルの確認
        assert "カウンターアプリ" in self.driver.title
        
        # ヘッダーの確認
        header = self.driver.find_element(By.TAG_NAME, "h1")
        assert header.text == "カウンター"
        
        # 初期カウント値の確認
        assert self.get_counter_value() == 0
        
        # ボタンの存在確認
        increase_btn = self.driver.find_element(By.ID, "increaseBtn")
        decrease_btn = self.driver.find_element(By.ID, "decreaseBtn")
        
        assert increase_btn.is_displayed()
        assert decrease_btn.is_displayed()
        assert increase_btn.text == "+"
        assert decrease_btn.text == "-"
    
    def test_counter_increase_functionality(self):
        """カウンター増加機能のテスト"""
        self.navigate_to_app()
        
        # 初期値確認
        assert self.get_counter_value() == 0
        
        # +ボタンをクリック
        self.click_increase_button()
        assert self.get_counter_value() == 1
        
        # さらに4回クリック
        for i in range(4):
            self.click_increase_button()
        
        assert self.get_counter_value() == 5
    
    def test_counter_decrease_functionality(self):
        """カウンター減少機能のテスト"""
        self.navigate_to_app()
        
        # -ボタンをクリック
        self.click_decrease_button()
        assert self.get_counter_value() == -1
        
        # さらに4回クリック
        for i in range(4):
            self.click_decrease_button()
        
        assert self.get_counter_value() == -5
    
    def test_counter_mixed_operations(self):
        """増加と減少の組み合わせテスト"""
        self.navigate_to_app()
        
        # +ボタンを3回
        for i in range(3):
            self.click_increase_button()
        assert self.get_counter_value() == 3
        
        # -ボタンを1回
        self.click_decrease_button()
        assert self.get_counter_value() == 2
        
        # -ボタンを5回
        for i in range(5):
            self.click_decrease_button()
        assert self.get_counter_value() == -3

テスト実行とレポート生成

docker-compose.ymlの内容

version: '3.8'

services:
  # カウンターアプリケーション
  counter-app:
    build:
      context: ../counter-app
      dockerfile: counter-app.Dockerfile
    ports:
      - "3000:3000"
    networks:
      - test-network
    
  # Python E2Eテスト
  e2e-tests:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./reports:/app/reports
    networks:
      - test-network
    depends_on:
      - counter-app
    environment:
      - APP_URL=http://counter-app:3000
      - DISPLAY=:99

networks:
  test-network:
    driver: bridge

テスト実行

# Docker Compose でテスト実行
docker-compose up -d counter-app
docker-compose run --rm e2e-tests

# レポートが reports/report.html に生成される

実際に実行したreport.htmlが以下です。

テストレポート生成例

感想

この記事では、DockerとSeleniumを組み合わせたE2Eテスト環境の構築方法をご紹介しました。実際にやる敷居がAIによって低くなったと実感した内容でした!

今回では、行わなかったですが、以下のようなこともできると思っています。

  • 複数ブラウザ対応: Chrome、Edgeなど他ブラウザへの展開
  • 並列実行: pytest-xdistを使った並列テスト実行
  • ビジュアルテスト: スクリーンショット比較によるビジュアルリグレッションテスト
  • APIテスト統合: SeleniumとAPIテストの組み合わせ

今後も引き続き、遊び心で作成していこうと思います!