Bilup 开发中的测试
测试是 Bilup 开发的关键部分,确保代码质量、防止回归并在整个平台上保持可靠性。本指南涵盖 Bilup 开发的测试策略、工具和最佳实践。
测试策略
测试金字塔
Bilup 遵循全面的测试金字塔:
- 单元测试 (70%):测试单个函数和组件
- 集成测试 (20%):测试组件交互
- 端到端测试 (10%):测试完整的用户工作流程
测试类型
组件测试
隔离测试 React 组件:
import { render, fireEvent, screen } from '@testing-library/react';
import { BlocksComponent } from '../blocks-component';
describe('BlocksComponent', () => {
test('正确渲染工作区', () => {
render(<BlocksComponent />);
expect(screen.getByRole('main')).toBeInTheDocument();
});
});
VM 测试
测试虚拟机功能:
import VirtualMachine from 'scratch-vm';
describe('VirtualMachine', () => {
let vm;
beforeEach(() => {
vm = new VirtualMachine();
});
test('应该执行简单脚本', async () => {
const project = createTestProject();
await vm.loadProject(project);
vm.greenFlag();
// 测试执行逻辑
});
});
测试设置
Jest 配置
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/test/setup.js'],
moduleNameMapping: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
'\\.(png|jpg|jpeg|gif|svg)$': '<rootDir>/test/__mocks__/fileMock.js'
},
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/playground/**'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
测试环境设置
// test/setup.js
import '@testing-library/jest-dom';
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });
// 全局测试工具
global.fetch = require('jest-fetch-mock');
global.URL.createObjectURL = jest.fn();
// Mock VM
global.VirtualMachine = require('scratch-vm');
运行测试
测试命令
# 运行所有测试
npm test
# 在监听模式下运行测试
npm run test:watch
# 运行特定测试文件
npm test -- blocks-component.test.js
# 运行测试并生成覆盖率报告
npm run test:coverage
# 运行集成测试
npm run test:integration
# 运行端到端测试
npm run test:e2e
测试脚本
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:integration": "jest --testMatch='**/integration/**/*.test.js'",
"test:e2e": "cypress run",
"test:e2e:open": "cypress open"
}
}
单元测试
组件测试
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import SpriteSelector from '../sprite-selector';
const mockStore = configureStore([]);
describe('SpriteSelector', () => {
let store;
beforeEach(() => {
store = mockStore({
targets: {
sprites: [
{ id: '1', name: 'Sprite1' },
{ id: '2', name: 'Sprite2' }
],
editingTarget: '1'
}
});
});
test('正确显示角色', () => {
const { getByText } = render(
<Provider store={store}>
<SpriteSelector />
</Provider>
);
expect(getByText('Sprite1')).toBeInTheDocument();
expect(getByText('Sprite2')).toBeInTheDocument();
});
test('处理角色选择', () => {
const onSelect = jest.fn();
const { getByText } = render(
<Provider store={store}>
<SpriteSelector onSelectSprite={onSelect} />
</Provider>
);
fireEvent.click(getByText('Sprite2'));
expect(onSelect).toHaveBeenCalledWith('2');
});
});
Redux 测试
import reducer from '../reducers/targets';
import * as actions from '../actions/targets';
describe('targets reducer', () => {
test('should handle UPDATE_TARGETS', () => {
const initialState = { sprites: [], editingTarget: null };
const action = actions.updateTargets([
{ id: '1', name: 'Sprite1', isStage: false }
]);
const expectedState = {
sprites: [{ id: '1', name: 'Sprite1', isStage: false }],
editingTarget: null
};
expect(reducer(initialState, action)).toEqual(expectedState);
});
});
集成测试
VM 集成
describe('VM 集成', () => {
let vm;
let gui;
beforeEach(() => {
vm = new VirtualMachine();
gui = mount(<GUI vm={vm} />);
});
test('正确加载项目', async () => {
const project = createTestProject();
await vm.loadProject(project);
await waitFor(() => {
expect(gui.find('SpriteSelector')).toHaveLength(1);
expect(gui.find('BlocksWorkspace')).toHaveLength(1);
});
});
test('正确执行积木', async () => {
const project = createProjectWithMoveBlock(10);
await vm.loadProject(project);
const sprite = vm.runtime.targets[1];
const initialX = sprite.x;
vm.greenFlag();
await vm.runtime.sequencer.stepAll();
expect(sprite.x).toBe(initialX + 10);
});
});
组件集成
describe('组件集成', () => {
test('积木和舞台正确通信', async () => {
const store = createMockStore();
const wrapper = mount(
<Provider store={store}>
<div>
<BlocksComponent />
<StageComponent />
</div>
</Provider>
);
// 模拟积木创建
const workspace = Blockly.getMainWorkspace();
const block = workspace.newBlock('motion_movesteps');
block.setFieldValue('10', 'STEPS');
// 执行积木
vm.runtime.executeBlock(block);
// 检查舞台更新
await waitFor(() => {
const stage = wrapper.find('StageComponent');
expect(stage.prop('sprite').x).toBe(10);
});
});
});
端到端测试
Cypress 设置
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:8601',
supportFile: 'cypress/support/e2e.js',
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
video: true,
screenshotOnRunFailure: true
}
});
E2E 测试示例
// cypress/e2e/project-loading.cy.js
describe('项目加载', () => {
it('应该从 URL 加载项目', () => {
cy.visit('/123456789');
// 等待项目加载
cy.get('[data-testid="loading-spinner"]').should('be.visible');
cy.get('[data-testid="loading-spinner"]').should('not.exist');
// 检查项目正确加载
cy.get('[data-testid="stage"]').should('be.visible');
cy.get('[data-testid="sprite-selector"]').should('contain', 'Sprite1');
});
it('应该运行简单项目', () => {
cy.visit('/123456789');
cy.wait(2000); // 等待加载
// 点击绿旗
cy.get('[data-testid="green-flag"]').click();
// 检查角色移动
cy.get('[data-testid="sprite-1"]')
.should('have.attr', 'transform')
.and('include', 'translate');
});
});
自定义命令
// cypress/support/commands.js
Cypress.Commands.add('loadProject', (projectId) => {
cy.visit(`/${projectId}`);
cy.get('[data-testid="loading-spinner"]').should('not.exist');
});
Cypress.Commands.add('createSprite', (name) => {
cy.get('[data-testid="add-sprite"]').click();
cy.get('[data-testid="sprite-name-input"]').type(name);
cy.get('[data-testid="create-sprite-button"]').click();
});
Cypress.Commands.add('addBlock', (blockType) => {
cy.get(`[data-block="${blockType}"]`).dragTo('[data-testid="workspace"]');
});
性能测试
负载测试
describe('性能测试', () => {
test('应该处理大型项目', async () => {
const largeProject = createProjectWithManySprites(100);
const startTime = performance.now();
await vm.loadProject(largeProject);
const loadTime = performance.now() - startTime;
expect(loadTime).toBeLessThan(5000); // 最多 5 秒
});
test('应该保持良好 FPS', async () => {
const project = createAnimationProject();
await vm.loadProject(project);
const fps = await measureFPS(() => {
vm.greenFlag();
return new Promise(resolve => setTimeout(resolve, 5000));
});
expect(fps).toBeGreaterThan(30);
});
});
内存测试
test('不应泄漏内存', async () => {
const initialMemory = getMemoryUsage();
// 加载和卸载多个项目
for (let i = 0; i < 10; i++) {
const project = createTestProject();
await vm.loadProject(project);
vm.clear();
}
// 强制垃圾回收
if (global.gc) global.gc();
const finalMemory = getMemoryUsage();
const memoryIncrease = finalMemory - initialMemory;
expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); // 最多 50MB
});
测试工具
Mock 工厂
// test/factories.js
export const createMockVM = () => ({
runtime: {
targets: [],
sequencer: { stepAll: jest.fn() },
getTargetForStage: jest.fn()
},
loadProject: jest.fn(),
greenFlag: jest.fn(),
stopAll: jest.fn()
});
export const createMockSprite = (overrides = {}) => ({
id: 'sprite-1',
name: 'Sprite1',
x: 0,
y: 0,
direction: 90,
size: 100,
visible: true,
...overrides
});
export const createTestProject = () => ({
targets: [
createMockSprite({ isStage: true, name: 'Stage' }),
createMockSprite({ name: 'Sprite1' })
],
monitors: [],
extensions: []
});
测试辅助函数
// test/helpers.js
export const waitForVM = (vm, event) => {
return new Promise(resolve => {
vm.once(event, resolve);
});
};
export const measureFPS = async (testFunction) => {
const frames = [];
const startTime = performance.now();
const measureFrame = () => {
frames.push(performance.now());
if (performance.now() - startTime < 5000) {
requestAnimationFrame(measureFrame);
}
};
requestAnimationFrame(measureFrame);
await testFunction();
return frames.length / 5; // 5秒内的平均 FPS
};
持续集成
GitHub Actions
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:coverage
- name: Run integration tests
run: npm run test:integration
- name: Upload coverage
uses: codecov/codecov-action@v2
测试覆盖率
# 生成覆盖率报告
npm run test:coverage
# 在浏览器中查看覆盖率
open coverage/lcov-report/index.html
# 在 package.json 中设置覆盖率阈值
{
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
Bilup 的测试确保了所有组件的代码质量和可靠性。遵循这些实践来维护一个用户可以依赖的健壮、经过良好测试的代码库!