操作
バグ #761
未完了【改善】インフラヘルパー - 単体テスト・統合テスト実装
ステータス:
新規
優先度:
高め
担当者:
-
開始日:
2025-06-26
期日:
進捗率:
0%
予定工数:
説明
🎯 概要¶
インフラヘルパーサービスの単体テスト・統合テストを実装し、コードカバレッジ80%以上を達成する。
📋 実装内容¶
1. テストフレームワークのセットアップ
package.json¶
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:integration": "jest --testMatch='**/*.integration.test.js'"
},
"devDependencies": {
"jest": "^29.7.0",
"@types/jest": "^29.5.12",
"supertest": "^6.3.4",
"node-mocks-http": "^1.14.1"
},
"jest": {
"testEnvironment": "node",
"coverageDirectory": "coverage",
"collectCoverageFrom": [
"backend/**/*.js",
"!backend/**/*.test.js",
"!backend/node_modules/**"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
2. 互換性ユーティリティのテスト
backend/tests/utils/compatibility.test.js¶
const {
isBusyBoxEnvironment,
getSystemUptime,
getMemoryInfo,
formatUptime,
formatBytes
} = require('../../utils/compatibility');
const { execSync } = require('child_process');
const fs = require('fs');
jest.mock('child_process');
jest.mock('fs');
describe('Compatibility Utils', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('isBusyBoxEnvironment', () => {
it('should detect BusyBox environment', async () => {
execSync.mockImplementation((cmd) => {
if (cmd.includes('which uptime')) return '';
if (cmd.includes('uptime --help')) return 'BusyBox v1.37.0';
return '';
});
const result = await isBusyBoxEnvironment();
expect(result).toBe(true);
});
it('should detect non-BusyBox environment', async () => {
execSync.mockImplementation((cmd) => {
if (cmd.includes('which uptime')) return '/usr/bin/uptime';
if (cmd.includes('uptime --help')) return 'uptime from procps-ng';
return '';
});
const result = await isBusyBoxEnvironment();
expect(result).toBe(false);
});
});
describe('formatUptime', () => {
it('should format seconds correctly', () => {
expect(formatUptime(0)).toBe('up 0 minutes');
expect(formatUptime(59)).toBe('up 0 minutes');
expect(formatUptime(60)).toBe('up 1 minute');
expect(formatUptime(3600)).toBe('up 1 hour');
expect(formatUptime(3661)).toBe('up 1 hour, 1 minute');
expect(formatUptime(86400)).toBe('up 1 day');
expect(formatUptime(90061)).toBe('up 1 day, 1 hour, 1 minute');
expect(formatUptime(172800)).toBe('up 2 days');
});
});
describe('formatBytes', () => {
it('should format bytes correctly', () => {
expect(formatBytes(0)).toBe('0.0B');
expect(formatBytes(512)).toBe('512.0B');
expect(formatBytes(1024)).toBe('1.0K');
expect(formatBytes(1536)).toBe('1.5K');
expect(formatBytes(1048576)).toBe('1.0M');
expect(formatBytes(1073741824)).toBe('1.0G');
expect(formatBytes(1099511627776)).toBe('1.0T');
});
});
describe('getSystemUptime', () => {
it('should read uptime from /proc/uptime', async () => {
execSync.mockImplementation(() => '90061.23 360244.92');
const uptime = await getSystemUptime();
expect(uptime).toBe('up 1 day, 1 hour, 1 minute');
});
it('should fallback to basic uptime command', async () => {
execSync.mockImplementation((cmd) => {
if (cmd.includes('/proc/uptime')) {
throw new Error('File not found');
}
return ' 10:30:45 up 2 days, 3:45, 2 users, load average: 0.00, 0.01, 0.05';
});
const uptime = await getSystemUptime();
expect(uptime).toBe('up 2 days, 3:45');
});
it('should return Unknown on all failures', async () => {
execSync.mockImplementation(() => {
throw new Error('Command failed');
});
const uptime = await getSystemUptime();
expect(uptime).toBe('Unknown');
});
});
describe('getMemoryInfo', () => {
it('should parse /proc/meminfo correctly', async () => {
const mockMeminfo = `
MemTotal: 8192000 kB
MemFree: 2048000 kB
MemAvailable: 4096000 kB
Buffers: 512000 kB
Cached: 1024000 kB
`;
execSync.mockImplementation(() => mockMeminfo);
const memInfo = await getMemoryInfo();
expect(memInfo).toMatchObject({
total: '7.8G',
used: '3.9G',
free: '2.0G',
available: '3.9G',
buffers: '500.0M',
cached: '1.0G',
percentage: '50.0'
});
});
it('should fallback to free command', async () => {
execSync.mockImplementation((cmd) => {
if (cmd.includes('/proc/meminfo')) {
throw new Error('File not found');
}
return 'Mem: 7.8G 3.9G 2.0G 0.1G 1.9G 3.7G';
});
const memInfo = await getMemoryInfo();
expect(memInfo).toMatchObject({
total: '7.8G',
used: '3.9G',
free: '2.0G',
percentage: 'N/A'
});
});
});
});
3. 認証機能のテスト
backend/tests/auth.test.js¶
const request = require('supertest');
const express = require('express');
const { router, authenticateToken, validateRedmineApiKey } = require('../auth');
const jwt = require('jsonwebtoken');
const fetch = require('node-fetch');
jest.mock('node-fetch');
const app = express();
app.use(express.json());
app.use('/auth', router);
describe('Auth Module', () => {
describe('POST /auth/login', () => {
it('should login with valid API key', async () => {
fetch.mockResolvedValue({
ok: true,
json: async () => ({
user: {
id: 1,
login: 'testuser',
admin: true,
email: 'test@example.com'
}
})
});
const response = await request(app)
.post('/auth/login')
.send({ apiKey: 'valid-api-key' });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.token).toBeDefined();
expect(response.body.user.login).toBe('testuser');
});
it('should reject invalid API key', async () => {
fetch.mockResolvedValue({
ok: false,
status: 401
});
const response = await request(app)
.post('/auth/login')
.send({ apiKey: 'invalid-api-key' });
expect(response.status).toBe(401);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Invalid Redmine API key');
});
it('should reject missing API key', async () => {
const response = await request(app)
.post('/auth/login')
.send({});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe('authenticateToken middleware', () => {
it('should authenticate valid token', async () => {
const mockReq = {
headers: {
authorization: 'Bearer valid-token'
}
};
const mockRes = {};
const mockNext = jest.fn();
jwt.verify = jest.fn().mockReturnValue({
user: { id: 1, login: 'testuser' }
});
await authenticateToken(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockReq.user).toEqual({ id: 1, login: 'testuser' });
});
it('should reject missing token', async () => {
const mockReq = { headers: {} };
const mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
const mockNext = jest.fn();
await authenticateToken(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({
success: false,
error: 'Access token required'
});
expect(mockNext).not.toHaveBeenCalled();
});
});
});
4. 統合テスト
backend/tests/integration/api.integration.test.js¶
const request = require('supertest');
const Docker = require('dockerode');
const app = require('../../server');
jest.mock('dockerode');
describe('API Integration Tests', () => {
let authToken;
beforeAll(async () => {
// Mock Redmine API
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
user: {
id: 1,
login: 'testuser',
admin: true,
email: 'test@example.com'
}
})
});
// Get auth token
const response = await request(app)
.post('/api/v1/auth/login')
.send({ apiKey: 'test-api-key' });
authToken = response.body.token;
});
describe('GET /api/v1/health', () => {
it('should return health status', async () => {
const response = await request(app)
.get('/api/v1/health');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
status: 'healthy',
version: '1.2.0',
features: expect.arrayContaining(['auth', 'docker', 'nginx'])
});
});
});
describe('GET /api/v1/vps/status', () => {
it('should return VPS status with auth', async () => {
// Mock Docker API
Docker.prototype.listContainers = jest.fn().mockResolvedValue([
{ Names: ['/test-container'], State: 'running' }
]);
const response = await request(app)
.get('/api/v1/vps/status')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toMatchObject({
containers: 1,
totalContainers: 1,
uptime: expect.any(String),
memory: expect.any(String)
});
});
it('should reject without auth', async () => {
const response = await request(app)
.get('/api/v1/vps/status');
expect(response.status).toBe(401);
});
});
describe('GET /api/v1/docker/containers', () => {
it('should list Docker containers', async () => {
Docker.prototype.listContainers = jest.fn().mockResolvedValue([
{
Names: ['/test-container'],
Image: 'nginx:latest',
Status: 'Up 2 hours',
State: 'running',
Created: 1234567890,
Ports: []
}
]);
const response = await request(app)
.get('/api/v1/docker/containers')
.set('Authorization', `Bearer ${authToken}`);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0]).toMatchObject({
name: 'test-container',
image: 'nginx:latest',
state: 'running'
});
});
});
});
5. GitHub Actions CI設定
.github/workflows/test.yml¶
name: Test
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: |
cd backend
npm ci
- name: Run tests
run: |
cd backend
npm test
- name: Generate coverage report
run: |
cd backend
npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
directory: ./backend/coverage
flags: unittests
name: codecov-umbrella
📊 期待される効果¶
-
品質向上
- バグの早期発見
- リグレッションの防止
- リファクタリングの安全性確保
-
開発効率
- TDDによる設計改善
- デバッグ時間の短縮
- ドキュメントとしてのテスト
-
信頼性向上
- 本番環境へのデプロイ前の品質保証
- カバレッジによる未テスト箇所の可視化
🔧 実装計画¶
- テストフレームワークセットアップ(0.5日)
- ユーティリティ関数のテスト作成(1日)
- 認証機能のテスト作成(1日)
- API統合テスト作成(2日)
- CI/CD設定(0.5日)
✅ 完了条件¶
- Jest環境のセットアップ完了
- 単体テストカバレッジ80%以上
- 統合テストで主要APIエンドポイントをカバー
- CI/CDパイプラインでテスト自動実行
- テスト実行時間5分以内
表示するデータがありません
操作