# TDD_PLAN.md - 测试计划 本文档规划 OpsXShell 的测试策略,采用 **先有后优** 的开发理念。 **核心理念**: 先确保功能完整可用,再逐步建立自动化测试覆盖 --- ## 测试策略 ### 当前阶段 (v1.0.0) ``` 功能实现 → 手动验证 → 测试框架 → 单元测试 → 集成测试 ↑__________________________________________________| ``` **原则**: 1. 功能优先:先实现并手动验证功能可用 2. 测试跟进:功能稳定后建立自动化测试 3. 逐步覆盖:从核心模块开始扩展测试范围 4. 集成验证:确保 IPC 通信和端到端流程正确 --- ## 第一阶段:测试框架建立 (P0) ### 1.1 安装测试依赖 ```bash # Vitest + React Testing Library npm install -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom # Playwright E2E npm install -D @playwright/test npx playwright install chromium ``` ### 1.2 配置文件 ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, environment: 'jsdom', setupFiles: ['./tests/setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'html'], }, }, }) ``` ```typescript // tests/setup.ts import '@testing-library/jest-dom' // Mock electron API const mockElectronAPI = { sshConnect: vi.fn(), sshDisconnect: vi.fn(), sshCreateShell: vi.fn(), sshShellWrite: vi.fn(), sshShellResize: vi.fn(), sessionGetAll: vi.fn(), sessionGetById: vi.fn(), sessionCreate: vi.fn(), sessionUpdate: vi.fn(), sessionDelete: vi.fn(), sftpConnect: vi.fn(), sftpListDirectory: vi.fn(), sftpUpload: vi.fn(), sftpDownload: vi.fn(), onShellData: vi.fn(), removeAllListeners: vi.fn(), } window.electronAPI = mockElectronAPI as any ``` --- ## 第二阶段:单元测试 (P0) ### 2.1 Store 测试 #### tabStore.test.ts ```typescript import { describe, it, expect, beforeEach } from 'vitest' import { useTabStore } from '../../src/stores/tabStore' describe('useTabStore', () => { beforeEach(() => { // Reset store useTabStore.setState({ tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }], activeTabId: 'welcome', }) }) describe('addTab', () => { it('should add a new terminal tab', () => { const { addTab } = useTabStore.getState() addTab({ title: 'Test', type: 'terminal' }) const { tabs } = useTabStore.getState() expect(tabs).toHaveLength(2) expect(tabs[1].title).toBe('Test') }) it('should activate new tab', () => { const { addTab } = useTabStore.getState() addTab({ title: 'Test', type: 'terminal' }) const { activeTabId } = useTabStore.getState() expect(activeTabId).toBe('tab-1') }) it('should not duplicate settings tab', () => { const { addTab } = useTabStore.getState() addTab({ title: '设置', type: 'settings' }) addTab({ title: '设置2', type: 'settings' }) const { tabs } = useTabStore.getState() expect(tabs.filter(t => t.type === 'settings')).toHaveLength(1) }) }) describe('closeTab', () => { it('should close tab and activate next', () => { const { addTab, closeTab, tabs } = useTabStore.getState() addTab({ title: 'Tab1', type: 'terminal' }) const tabToClose = tabs[1].id closeTab(tabToClose) const { tabs: newTabs } = useTabStore.getState() expect(newTabs).toHaveLength(1) }) it('should not close last tab', () => { const { closeTab, tabs } = useTabStore.getState() closeTab(tabs[0].id) const { tabs: newTabs } = useTabStore.getState() expect(newTabs).toHaveLength(1) }) }) describe('connectionStatus', () => { it('should update tab connection status', () => { const { addTab, updateTabStatus } = useTabStore.getState() addTab({ title: 'SSH', type: 'terminal' }) const tabId = useTabStore.getState().tabs[1].id updateTabStatus(tabId, 'connected') const { tabs } = useTabStore.getState() expect(tabs.find(t => t.id === tabId)?.connectionStatus).toBe('connected') }) }) }) ``` #### connectionStore.test.ts ```typescript import { describe, it, expect, beforeEach, vi } from 'vitest' import { useConnectionStore } from '../../src/stores/connectionStore' describe('useConnectionStore', () => { beforeEach(() => { useConnectionStore.setState({ sessions: [] }) vi.clearAllMocks() }) describe('addSession', () => { it('should call sessionCreate API', async () => { window.electronAPI.sessionCreate.mockResolvedValue({ success: true, session: { id: 'test-1', name: 'Test', host: 'localhost' }, }) const { addSession } = useConnectionStore.getState() await addSession({ name: 'Test', host: 'localhost', port: 22, username: 'user', authType: 'password', }) expect(window.electronAPI.sessionCreate).toHaveBeenCalled() }) }) describe('removeSession', () => { it('should remove session from state', async () => { useConnectionStore.setState({ sessions: [{ id: 'test-1', name: 'Test', host: 'localhost' }], }) const { removeSession } = useConnectionStore.getState() await removeSession('test-1') const { sessions } = useConnectionStore.getState() expect(sessions).toHaveLength(0) }) }) }) ``` ### 2.2 组件测试 #### TerminalView.test.tsx ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import TerminalView from '../../src/components/TerminalView' // Mock xterm vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn().mockImplementation(() => ({ onData: vi.fn(), onResize: vi.fn(), open: vi.fn(), write: vi.fn(), dispose: vi.fn(), cols: 80, rows: 24, })), })) vi.mock('@xterm/addon-fit', () => ({ FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn(), loadAddon: vi.fn(), })), })) describe('TerminalView', () => { beforeEach(() => { vi.clearAllMocks() }) it('should render terminal container', () => { render() expect(screen.getByText('连接成功!')).toBeDefined() }) it('should show connecting status', () => { render() expect(screen.getByText('正在连接...')).toBeDefined() }) }) ``` #### Sidebar.test.tsx ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import Sidebar from '../../src/components/Sidebar' describe('Sidebar', () => { beforeEach(() => { vi.clearAllMocks() window.electronAPI.sessionGetAll.mockResolvedValue({ success: true, sessions: [], }) }) it('should render quick connect button', () => { render() expect(screen.getByText('快速连接')).toBeDefined() }) it('should call onQuickConnect when button clicked', () => { const onQuickConnect = vi.fn() render() fireEvent.click(screen.getByText('快速连接')) expect(onQuickConnect).toHaveBeenCalled() }) it('should display sessions from API', async () => { window.electronAPI.sessionGetAll.mockResolvedValue({ success: true, sessions: [ { id: '1', name: 'Server 1', host: '192.168.1.1', port: 22 }, ], }) render() expect(await screen.findByText('Server 1')).toBeDefined() }) }) ``` --- ## 第三阶段:集成测试 (P1) ### 3.1 IPC 通信测试 ```typescript // tests/integration/ipc.test.ts describe('IPC Communication', () => { describe('SSH Operations', () => { it('should connect and create shell', async () => { // Mock successful connection window.electronAPI.sshConnect.mockResolvedValue({ success: true, connectionId: 'conn-1', }) window.electronAPI.sshCreateShell.mockResolvedValue({ success: true, shellId: 'shell-1', }) // Simulate connection flow const config = { host: 'test', port: 22, username: 'user', password: 'pass' } const connResult = await window.electronAPI.sshConnect(config) expect(connResult.success).toBe(true) expect(connResult.connectionId).toBe('conn-1') const shellResult = await window.electronAPI.sshCreateShell('conn-1', { cols: 80, rows: 24 }) expect(shellResult.success).toBe(true) }) }) describe('Session CRUD', () => { it('should create, read, update, delete session', async () => { // Create window.electronAPI.sessionCreate.mockResolvedValue({ success: true, session: { id: 'sess-1', name: 'Test', host: 'localhost' }, }) const createResult = await window.electronAPI.sessionCreate({ name: 'Test', host: 'localhost', port: 22, username: 'user', }) expect(createResult.success).toBe(true) // Update window.electronAPI.sessionUpdate.mockResolvedValue({ success: true, session: { id: 'sess-1', name: 'Updated', host: 'localhost' }, }) const updateResult = await window.electronAPI.sessionUpdate('sess-1', { name: 'Updated' }) expect(updateResult.success).toBe(true) expect(updateResult.session.name).toBe('Updated') // Delete window.electronAPI.sessionDelete.mockResolvedValue({ success: true }) const deleteResult = await window.electronAPI.sessionDelete('sess-1') expect(deleteResult.success).toBe(true) }) }) }) ``` ### 3.2 SFTP 集成测试 ```typescript // tests/integration/sftp.test.ts describe('SFTP Operations', () => { beforeEach(() => { window.electronAPI.sshConnect.mockResolvedValue({ success: true, connectionId: 'conn-1', }) window.electronAPI.sftpConnect.mockResolvedValue({ success: true, sftpId: 'sftp-1', }) }) it('should connect to SFTP and list directory', async () => { window.electronAPI.sftpListDirectory.mockResolvedValue({ success: true, files: [ { filename: 'file1.txt', attrs: { isDirectory: () => false, size: 1024 } }, { filename: 'dir1', attrs: { isDirectory: () => true, size: 0 } }, ], }) const sftpResult = await window.electronAPI.sftpConnect('conn-1') expect(sftpResult.success).toBe(true) const listResult = await window.electronAPI.sftpListDirectory('sftp-1', '/home/user') expect(listResult.success).toBe(true) expect(listResult.files).toHaveLength(2) }) }) ``` --- ## 第四阶段:E2E 测试 (P2) ### 4.1 Playwright 配置 ```typescript // playwright.config.ts import { defineConfig } from '@playwright/test' export default defineConfig({ testDir: './tests/e2e', timeout: 30000, use: { baseURL: 'http://localhost:5173', headless: true, }, webServer: { command: 'npm run dev', port: 5173, reuseExistingServer: true, }, }) ``` ### 4.2 E2E 测试用例 ```typescript // tests/e2e/connection.spec.ts import { test, expect } from '@playwright/test' test.describe('SSH Connection Flow', () => { test('should connect via quick connect dialog', async ({ page }) => { await page.goto('/') // Click quick connect await page.getByText('快速连接').click() // Fill form await page.getByPlaceholder('主机地址').fill('localhost') await page.getByPlaceholder('端 口').fill('22') await page.getByPlaceholder('用户名').fill('test') // Submit await page.getByText('连接').click() // Should show terminal await expect(page.locator('[data-testid="terminal"]')).toBeVisible() }) test('should connect via sidebar session', async ({ page }) => { await page.goto('/') // Add a session first (via API mock in real test) // Click session in sidebar await page.getByText('测试服务器').click() // Should open terminal tab await expect(page.locator('[data-tab-title="测试服务器"]')).toBeVisible() }) }) test.describe('SFTP Flow', () => { test('should show session selection when no session provided', async ({ page }) => { await page.goto('/') // Open SFTP await page.getByText('SFTP 传输').click() // Click connect await page.getByText('选择服务器').click() // Should show session list await expect(page.getByText('选择要连接的服务器')).toBeVisible() }) }) ``` --- ## 测试执行 ### 命令 ```bash # 运行所有测试 npm test # 带覆盖率 npm test -- --coverage # 监视模式 npm test -- --watch # E2E 测试 npm run test:e2e # 带 UI 的 Vitest npm run test:ui ``` ### CI 配置 ```yaml # .github/workflows/test.yml name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npm run lint - run: npm test -- --coverage - uses: codecov/codecov-action@v3 ``` --- ## 覆盖率目标 | 版本 | 单元测试 | 集成测试 | E2E | |------|----------|----------|-----| | v1.0.0 | 20% | 10% | 5% | | v1.1.0 | 40% | 20% | 10% | | v1.2.0 | 60% | 30% | 15% | --- ## 测试优先级矩阵 | 功能模块 | 单元测试 | 集成测试 | E2E | 优先级 | |----------|----------|----------|-----|--------| | tabStore | ✅ | - | - | P0 | | connectionStore | ✅ | ✅ | - | P0 | | TerminalView | ✅ | ✅ | ✅ | P0 | | SFTPView | ✅ | ✅ | ✅ | P1 | | Sidebar | ✅ | ✅ | ✅ | P1 | | SessionDialog | ✅ | ✅ | - | P1 | | ssh-service | - | ✅ | - | P0 | | database-service | - | ✅ | - | P1 |