🎄 科学と神々株式会社 アドベントカレンダー 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>
"""
🌟 まとめ
テスト戦略の要点:
-
単体テスト
- Nim unittestフレームワーク
- 80%以上のカバレッジ
- 高速・低コスト
-
統合テスト
- API + データベース連携
- HTTPクライアントテスト
- 実環境に近い検証
-
E2Eテスト
- ブラウザ拡張機能
- ユーザーシナリオ
- SeleniumなどのツールShikibu IME4. パフォーマンステスト
- レスポンスタイム測定
- 負荷テスト
- スループット確認
-
セキュリティテスト
- SQLインジェクション対策
- 認証バイパス防止
- CSRF/XSS対策
-
CI/CD統合
- GitHub Actions
- 自動テスト実行
- カバレッジレポート
前回: Day 20: 実際に動かしてみよう - セットアップガイド
次回: Day 22: 商用化への道 - スケーラビリティ
Happy Learning! 🎉