0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

License System Day 21: テストシナリオと検証方法

Last updated at Posted at 2025-12-20

🎄 科学と神々株式会社 アドベントカレンダー 2025

License System Day 21: テストシナリオと検証方法


📖 今日のテーマ

今日は、包括的なテスト戦略を学びます。

単体テストから統合テスト、E2Eテスト、パフォーマンステスト、セキュリティテストまで、Nimベースの実装で品質を保証する方法を解説します。


🎯 テスト戦略の全体像

テストピラミッド

        /\
       /E2E\         ← 少数(遅い・高コスト)
      /------\
     /Integration\   ← 中程度(中速・中コスト)
    /------------\
   / Unit Tests  \  ← 多数(高速・低コスト)
  /----------------\

テストカテゴリ

カテゴリ別テスト戦略:

1. 単体テスト (Unit Tests)
   ✅ 個別関数・モジュールの動作検証
   ✅ Nimのunittestフレームワーク使用
   ✅ カバレッジ目標: 80%以上

2. 統合テスト (Integration Tests)
   ✅ モジュール間の連携確認
   ✅ データベースとAPIの統合
   ✅ 実際のHTTPリクエスト検証

3. E2Eテスト (End-to-End Tests)
   ✅ ブラウザ拡張機能の動作確認
   ✅ ユーザーシナリオの完全な流れ
   ✅ Seleniumなどのツール使用

4. パフォーマンステスト
   ✅ レスポンスタイム測定
   ✅ 負荷テスト
   ✅ スループット確認

5. セキュリティテスト
   ✅ 認証・認可の検証
   ✅ SQLインジェクション対策
   ✅ CSRF/XSS対策

🧪 単体テスト (Unit Tests)

Nimのunittestフレームワーク

# server/tests/test_crypto.nim
import unittest
import ../src/crypto
import std/[json, times]

suite "CryptoService Tests":

  setup:
    # 各テスト前の初期化
    let cryptoService = newCryptoService(
      privateKeyPath = "../keys/private_key.pem",
      publicKeyPath = "../keys/public_key.pem"
    )

  teardown:
    # 各テスト後のクリーンアップ
    discard

  test "JWT generation creates valid token":
    # JWTトークン生成
    let token = cryptoService.generateJWT(
      userId = "user-001",
      email = "test@example.com",
      planType = "premium_monthly",
      expiresIn = 365
    )

    # トークンが空でないことを確認
    check token.len > 0

    # JWT形式(3つのパートをドットで区切り)を確認
    let parts = token.split('.')
    check parts.len == 3

  test "JWT verification succeeds for valid token":
    # トークン生成
    let token = cryptoService.generateJWT(
      userId = "user-001",
      email = "test@example.com",
      planType = "premium_monthly"
    )

    # トークン検証
    let claims = cryptoService.verifyJWT(token)

    check claims.isSome
    check claims.get["user_id"].getStr() == "user-001"
    check claims.get["email"].getStr() == "test@example.com"
    check claims.get["plan_type"].getStr() == "premium_monthly"

  test "JWT verification fails for tampered token":
    # 正常なトークン生成
    var token = cryptoService.generateJWT(
      userId = "user-001",
      email = "test@example.com",
      planType = "free"
    )

    # トークンを改ざん(最後の文字を変更)
    token[^1] = if token[^1] == 'a': 'b' else: 'a'

    # 検証が失敗することを確認
    let claims = cryptoService.verifyJWT(token)
    check claims.isNone

  test "JWT verification fails for expired token":
    # 既に期限切れのトークン(-1日)
    let token = cryptoService.generateJWT(
      userId = "user-001",
      email = "test@example.com",
      planType = "premium_monthly",
      expiresIn = -1  # 過去の日付
    )

    # 検証が失敗することを確認
    let claims = cryptoService.verifyJWT(token)
    check claims.isNone

データベース操作のテスト

# server/tests/test_database.nim
import unittest
import ../src/database
import std/[options, times]

suite "Database Tests":

  var db: Database

  setup:
    # インメモリデータベースでテスト
    db = newDatabase(":memory:")
    db.initSchema()

  teardown:
    db.close()

  test "User creation and retrieval":
    # ユーザー作成
    let userId = db.createUser(
      email = "test@example.com",
      passwordHash = "hashed_password_123"
    )

    check userId.len > 0

    # ユーザー取得
    let user = db.getUserByEmail("test@example.com")

    check user.isSome
    check user.get.email == "test@example.com"
    check user.get.passwordHash == "hashed_password_123"

  test "Duplicate email should fail":
    # 最初のユーザー
    discard db.createUser("test@example.com", "hash1")

    # 同じメールアドレスで2回目(エラーを期待)
    expect(ValueError):
      discard db.createUser("test@example.com", "hash2")

  test "Subscription creation and status check":
    # ユーザー作成
    let userId = db.createUser("test@example.com", "hash")

    # サブスクリプション作成
    let subId = db.createSubscription(
      userId = userId,
      planType = "premium_monthly",
      durationDays = 30
    )

    check subId.len > 0

    # アクティブなサブスクリプション取得
    let subscription = db.getActiveSubscription(userId)

    check subscription.isSome
    check subscription.get.planType == "premium_monthly"
    check subscription.get.status == "active"

  test "Rate limiting enforcement":
    # ユーザー作成
    let userId = db.createUser("test@example.com", "hash")

    # 10回リクエスト(制限内)
    for i in 1..10:
      let allowed = db.checkRateLimit(userId, "/api/v1/echo", 10)
      check allowed == true
      db.recordRequest(userId, "/api/v1/echo")

    # 11回目(制限超過)
    let allowed = db.checkRateLimit(userId, "/api/v1/echo", 10)
    check allowed == false

レート制限ロジックのテスト

# server/tests/test_rate_limit.nim
import unittest
import ../src/rate_limiter
import std/times

suite "Rate Limiter Tests":

  test "Sliding window allows requests within limit":
    var limiter = newSlidingWindowLimiter(maxRequests = 5, windowSec = 60)

    # 5回リクエスト(すべて許可)
    for i in 1..5:
      check limiter.allowRequest("user-001") == true

    # 6回目(拒否)
    check limiter.allowRequest("user-001") == false

  test "Token bucket refills over time":
    var limiter = newTokenBucketLimiter(
      capacity = 10,
      refillRate = 2.0  # 2 tokens/秒
    )

    # 10トークン消費
    for i in 1..10:
      check limiter.allowRequest("user-001") == true

    # 11回目(バケット空)
    check limiter.allowRequest("user-001") == false

    # 1秒待機(2トークン補充)
    sleep(1000)

    # 2回リクエスト可能
    check limiter.allowRequest("user-001") == true
    check limiter.allowRequest("user-001") == true
    check limiter.allowRequest("user-001") == false

🔗 統合テスト (Integration Tests)

APIエンドポイントのテスト

# server/tests/test_api_integration.nim
import unittest
import std/[httpclient, json, asyncdispatch]

suite "API Integration Tests":

  setup:
    # サーバーを起動(バックグラウンド)
    # 注: 実際にはテスト専用サーバーを別ポートで起動
    discard

  test "Health endpoint returns OK":
    let client = newHttpClient()
    let response = client.get("http://localhost:8080/health")

    check response.code == Http200

    let body = parseJson(response.body)
    check body["status"].getStr() == "ok"

  test "License activation flow":
    let client = newHttpClient()

    # ライセンス認証リクエスト
    let payload = %*{
      "email": "test@example.com",
      "password": "test123",
      "client_id": "test-client-001"
    }

    client.headers = newHttpHeaders({"Content-Type": "application/json"})
    let response = client.post(
      "http://localhost:8080/api/v1/license/activate",
      $payload
    )

    check response.code == Http200

    let body = parseJson(response.body)
    check body.hasKey("activation_key")
    check body["status"].getStr() == "activated"
    check body["plan_type"].getStr() in ["free", "premium_monthly", "enterprise_monthly"]

  test "Invalid credentials return 401":
    let client = newHttpClient()

    let payload = %*{
      "email": "wrong@example.com",
      "password": "wrongpassword",
      "client_id": "test-client-001"
    }

    client.headers = newHttpHeaders({"Content-Type": "application/json"})
    let response = client.post(
      "http://localhost:8080/api/v1/license/activate",
      $payload
    )

    check response.code == Http401

    let body = parseJson(response.body)
    check body.hasKey("error")

  test "Echo endpoint respects plan limits":
    let client = newHttpClient()

    # まず認証してトークン取得(Premiumプラン)
    # ... (認証コード省略)

    let token = "VALID_JWT_TOKEN_HERE"

    # 長いメッセージでエコー(Premiumは1000文字まで)
    let longMessage = "A".repeat(500)
    let payload = %*{"message": longMessage}

    client.headers = newHttpHeaders({
      "Content-Type": "application/json",
      "X-Activation-Key": token
    })

    let response = client.post(
      "http://localhost:8080/api/v1/echo",
      $payload
    )

    check response.code == Http200

    let body = parseJson(response.body)
    check body["echo"].getStr() == longMessage
    check body.hasKey("uppercase")  # Premium機能

データベースとAPI連携テスト

# server/tests/test_db_api_integration.nim
import unittest
import ../src/[database, main]
import std/[httpclient, json]

suite "Database-API Integration Tests":

  var db: Database

  setup:
    db = newDatabase("test_integration.db")
    db.initSchema()

    # テストユーザー作成
    let userId = db.createUser("integration@test.com", "hashed_password")
    discard db.createSubscription(userId, "premium_monthly", 30)

  teardown:
    db.close()
    removeFile("test_integration.db")

  test "End-to-end license activation with database":
    # APIリクエスト → データベース更新 → レスポンス確認
    let client = newHttpClient()

    let payload = %*{
      "email": "integration@test.com",
      "password": "test123",  # 実際のハッシュと照合
      "client_id": "integration-test-001"
    }

    client.headers = newHttpHeaders({"Content-Type": "application/json"})
    let response = client.post(
      "http://localhost:8080/api/v1/license/activate",
      $payload
    )

    check response.code == Http200

    # データベース側で検証
    let licenses = db.getLicensesByUser(userId)
    check licenses.len == 1
    check licenses[0].clientId == "integration-test-001"

🌐 E2Eテスト (End-to-End Tests)

ブラウザ拡張機能のテスト

# 注: E2EテストはSelenium WebDriverなどを使用
# ここではシナリオの疑似コードを示す

# tests/e2e/test_extension_flow.nim
import std/[json, httpclient, asyncdispatch]

proc testExtensionActivationFlow() {.async.} =
  ## ブラウザ拡張機能の完全なアクティベーションフロー

  # 1. ブラウザを起動
  let driver = await newWebDriver("chrome")

  # 2. 拡張機能をロード
  await driver.installExtension("../browser-extension/")

  # 3. 拡張機能ポップアップを開く
  await driver.clickExtensionIcon("license-system-client")

  # 4. ログインフォームに入力
  await driver.findElement("#email").sendKeys("test@example.com")
  await driver.findElement("#password").sendKeys("test123")
  await driver.findElement("#login-button").click()

  # 5. 認証成功の確認
  await driver.waitForElement(".success-message", timeout = 5000)

  let statusText = await driver.findElement(".license-status").getText()
  check statusText.contains("Premium")

  # 6. エコー機能テスト
  await driver.findElement("#message-input").sendKeys("Hello, E2E Test!")
  await driver.findElement("#echo-button").click()

  await driver.waitForElement(".echo-result", timeout = 3000)
  let echoResult = await driver.findElement(".echo-result").getText()
  check echoResult == "Hello, E2E Test!"

  # 7. クリーンアップ
  await driver.quit()

シナリオベーステスト

# tests/e2e/test_user_scenarios.nim
import unittest

suite "User Scenario Tests":

  test "Scenario: New user signs up and activates free plan":
    # 1. 新規ユーザー登録
    # 2. メール認証(省略可)
    # 3. 無料プランでアクティベート
    # 4. 10回/時のレート制限確認
    # 5. エコー機能で100文字制限確認
    discard

  test "Scenario: Premium user upgrades to Enterprise":
    # 1. Premiumプランでログイン
    # 2. Enterprise プランにアップグレード
    # 3. 無制限機能の確認
    # 4. 高度な機能アクセス確認
    discard

  test "Scenario: User device limit enforcement":
    # 1. 3台のデバイスで認証
    # 2. 4台目でエラー確認
    # 3. 1台削除
    # 4. 再度認証成功
    discard

⚡ パフォーマンステスト

レスポンスタイム測定

# tests/performance/test_response_time.nim
import unittest
import std/[httpclient, times, stats]

suite "Performance Tests":

  test "Echo endpoint response time under 100ms":
    let client = newHttpClient()
    var responseTimes: seq[float] = @[]

    # 100回リクエストして平均レスポンスタイム計測
    for i in 1..100:
      let startTime = cpuTime()

      let response = client.post(
        "http://localhost:8080/api/v1/echo",
        """{"message": "Performance test"}"""
      )

      let endTime = cpuTime()
      let responseTime = (endTime - startTime) * 1000.0  # ミリ秒

      responseTimes.add(responseTime)
      check response.code == Http200

    # 統計計算
    let avgTime = responseTimes.sum() / responseTimes.len.float
    let maxTime = responseTimes.max()
    let minTime = responseTimes.min()

    echo "Average response time: ", avgTime, "ms"
    echo "Max response time: ", maxTime, "ms"
    echo "Min response time: ", minTime, "ms"

    # 平均レスポンスタイムが100ms以下であることを確認
    check avgTime < 100.0

負荷テスト

# tests/performance/test_load.nim
import std/[httpclient, asyncdispatch, times]

proc loadTest(concurrentRequests: int, totalRequests: int) {.async.} =
  ## 並行リクエストでの負荷テスト

  var successCount = 0
  var errorCount = 0
  var totalTime = 0.0

  let startTime = cpuTime()

  # 並行リクエスト
  var futures: seq[Future[void]] = @[]

  for i in 1..totalRequests:
    futures.add(sendRequest())

  await all(futures)

  let endTime = cpuTime()
  let duration = endTime - startTime

  echo "Load Test Results:"
  echo "  Total requests: ", totalRequests
  echo "  Concurrent: ", concurrentRequests
  echo "  Duration: ", duration, "s"
  echo "  Throughput: ", totalRequests.float / duration, " req/s"
  echo "  Success: ", successCount
  echo "  Errors: ", errorCount

proc sendRequest() {.async.} =
  try:
    let client = newAsyncHttpClient()
    let response = await client.post(
      "http://localhost:8080/api/v1/echo",
      """{"message": "Load test"}"""
    )
    if response.code == Http200:
      successCount.inc
    else:
      errorCount.inc
  except:
    errorCount.inc

# 実行例
waitFor loadTest(concurrentRequests = 50, totalRequests = 1000)

🔒 セキュリティテスト

SQLインジェクション対策テスト

# tests/security/test_sql_injection.nim
import unittest
import ../src/database

suite "SQL Injection Tests":

  test "Malicious email input is safely handled":
    let db = newDatabase(":memory:")
    db.initSchema()

    # SQLインジェクション試行
    let maliciousEmail = "admin@example.com' OR '1'='1"

    # プリペアドステートメントで安全に処理される
    let user = db.getUserByEmail(maliciousEmail)

    # マッチしない(インジェクション失敗)
    check user.isNone

認証バイパステスト

# tests/security/test_auth_bypass.nim
import unittest
import std/[httpclient, json]

suite "Authentication Bypass Tests":

  test "Missing JWT token returns 401":
    let client = newHttpClient()

    # トークンなしでエコーエンドポイントにアクセス
    let response = client.post(
      "http://localhost:8080/api/v1/echo",
      """{"message": "Unauthorized"}"""
    )

    check response.code == Http401

  test "Invalid JWT signature is rejected":
    let client = newHttpClient()

    # 改ざんされたトークン
    let fakeToken = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.FAKE_PAYLOAD.FAKE_SIGNATURE"

    client.headers = newHttpHeaders({
      "X-Activation-Key": fakeToken
    })

    let response = client.post(
      "http://localhost:8080/api/v1/echo",
      """{"message": "Bypass attempt"}"""
    )

    check response.code == Http401

  test "Expired token is rejected":
    # 期限切れトークンでのアクセス試行
    # ... (実装省略)
    discard

CSRF対策テスト

# tests/security/test_csrf.nim
import unittest
import std/httpclient

suite "CSRF Protection Tests":

  test "Cross-origin requests without CORS are blocked":
    let client = newHttpClient()

    # 異なるオリジンからのリクエスト
    client.headers = newHttpHeaders({
      "Origin": "http://malicious-site.com"
    })

    let response = client.post(
      "http://localhost:8080/api/v1/echo",
      """{"message": "CSRF attempt"}"""
    )

    # CORSポリシーで拒否される
    # (実際にはブラウザレベルでブロック)
    check response.code in [Http403, Http401]

🌊 WebAssemblyクライアントのテスト

Node.js環境でのWASMテスト

// client-wasm/tests/test_license_client.js
const assert = require('assert');
const { LicenseClient } = require('../src/license_client.js');

describe('WebAssembly Client Tests', function() {

  it('should initialize license client', function() {
    const client = new LicenseClient('http://localhost:8080');
    assert.ok(client);
  });

  it('should activate license with valid credentials', async function() {
    const client = new LicenseClient('http://localhost:8080');

    const result = await client.activateLicense(
      'test@example.com',
      'test123'
    );

    assert.ok(result.activation_key);
    assert.equal(result.status, 'activated');
  });

  it('should validate license token', async function() {
    const client = new LicenseClient('http://localhost:8080');

    // 認証
    const activationResult = await client.activateLicense(
      'test@example.com',
      'test123'
    );

    // 検証
    const validationResult = await client.validateLicense(
      activationResult.activation_key
    );

    assert.equal(validationResult.status, 'valid');
    assert.ok(validationResult.premium);
  });
});

🔄 テスト自動化とCI/CD

GitHub Actionsでの自動テスト

# .github/workflows/test.yml
name: Automated Testing

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  unit-tests:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Install Nim
      run: |
        curl https://nim-lang.org/choosenim/init.sh -sSf | sh
        export PATH=$HOME/.nimble/bin:$PATH

    - name: Install dependencies
      run: |
        nimble install -y jester
        nimble install -y nimcrypto
        nimble install -y jwt

    - name: Run unit tests
      run: |
        cd server/tests
        nim c -r test_crypto.nim
        nim c -r test_database.nim
        nim c -r test_rate_limit.nim

    - name: Generate coverage report
      run: |
        nimble test --coverage
        nimble coverage --report=html

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        files: ./coverage.xml

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests

    steps:
    - uses: actions/checkout@v3

    - name: Start test server
      run: |
        cd server
        nim c -d:release src/main.nim
        ./src/main &
        sleep 5

    - name: Run integration tests
      run: |
        cd server/tests
        nim c -r test_api_integration.nim

  e2e-tests:
    runs-on: ubuntu-latest
    needs: integration-tests

    steps:
    - uses: actions/checkout@v3

    - name: Install Chrome
      uses: browser-actions/setup-chrome@latest

    - name: Run E2E tests
      run: |
        cd tests/e2e
        nim c -r test_extension_flow.nim

テストカバレッジレポート

# 注: Nimbleのcoverageプラグインを使用
# nimble.tomlに追加:
# [coverage]
# enabled = true
# format = "html"
# output = "coverage_report/"

# カバレッジ実行コマンド
# $ nimble test --coverage
# $ nimble coverage --report=html

# カバレッジ目標:
# ✅ 80%以上: 合格
# ⚠️  60-80%: 改善推奨
# ❌ 60%未満: 不合格

📊 テスト結果の可視化

テストサマリーレポート

# tests/utils/test_reporter.nim
import std/[json, strutils, times]

type
  TestResult* = object
    name*: string
    status*: string  # "passed", "failed", "skipped"
    duration*: float
    error*: string

  TestSuite* = object
    name*: string
    tests*: seq[TestResult]
    totalDuration*: float

proc generateReport*(suites: seq[TestSuite]): string =
  ## HTMLテストレポート生成

  var totalTests = 0
  var passedTests = 0
  var failedTests = 0

  for suite in suites:
    totalTests += suite.tests.len
    for test in suite.tests:
      if test.status == "passed":
        passedTests.inc
      elif test.status == "failed":
        failedTests.inc

  result = """
<!DOCTYPE html>
<html>
<head>
  <title>Test Results</title>
  <style>
    .passed { color: green; }
    .failed { color: red; }
  </style>
</head>
<body>
  <h1>Test Results Summary</h1>
  <p>Total: """ & $totalTests & """</p>
  <p class="passed">Passed: """ & $passedTests & """</p>
  <p class="failed">Failed: """ & $failedTests & """</p>

  <h2>Test Suites</h2>
"""

  for suite in suites:
    result &= "<h3>" & suite.name & "</h3><ul>"
    for test in suite.tests:
      let statusClass = if test.status == "passed": "passed" else: "failed"
      result &= "<li class=\"" & statusClass & "\">" & test.name & " (" & $test.duration & "ms)</li>"
    result &= "</ul>"

  result &= """
</body>
</html>
"""

🌟 まとめ

テスト戦略の要点:

  1. 単体テスト

    • Nim unittestフレームワーク
    • 80%以上のカバレッジ
    • 高速・低コスト
  2. 統合テスト

    • API + データベース連携
    • HTTPクライアントテスト
    • 実環境に近い検証
  3. E2Eテスト

    • ブラウザ拡張機能
    • ユーザーシナリオ
    • SeleniumなどのツールShikibu IME4. パフォーマンステスト
    • レスポンスタイム測定
    • 負荷テスト
    • スループット確認
  4. セキュリティテスト

    • SQLインジェクション対策
    • 認証バイパス防止
    • CSRF/XSS対策
  5. CI/CD統合

    • GitHub Actions
    • 自動テスト実行
    • カバレッジレポート

前回: Day 20: 実際に動かしてみよう - セットアップガイド
次回: Day 22: 商用化への道 - スケーラビリティ

Happy Learning! 🎉

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?