プロジェクト

全般

プロフィール

バグ #761

未完了

【改善】インフラヘルパー - 単体テスト・統合テスト実装

Redmine Admin さんが5日前に追加.

ステータス:
新規
優先度:
高め
担当者:
-
開始日:
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

📊 期待される効果

  1. 品質向上

    • バグの早期発見
    • リグレッションの防止
    • リファクタリングの安全性確保
  2. 開発効率

    • TDDによる設計改善
    • デバッグ時間の短縮
    • ドキュメントとしてのテスト
  3. 信頼性向上

    • 本番環境へのデプロイ前の品質保証
    • カバレッジによる未テスト箇所の可視化

🔧 実装計画

  1. テストフレームワークセットアップ(0.5日)
  2. ユーティリティ関数のテスト作成(1日)
  3. 認証機能のテスト作成(1日)
  4. API統合テスト作成(2日)
  5. CI/CD設定(0.5日)

✅ 完了条件

  • Jest環境のセットアップ完了
  • 単体テストカバレッジ80%以上
  • 統合テストで主要APIエンドポイントをカバー
  • CI/CDパイプラインでテスト自動実行
  • テスト実行時間5分以内

表示するデータがありません

他の形式にエクスポート: Atom PDF