Chapter 10: Testing and Maintenance
Haiyue
26min
Learning Objectives
- Establish unit testing and integration testing frameworks
- Implement end-to-end testing and performance testing
- Configure CI/CD pipelines
- Establish code quality and monitoring systems
Key Concepts
Testing Strategy Pyramid
Astro application testing strategy follows the testing pyramid principle:
- Unit Testing: Independent testing of components and utility functions
- Integration Testing: API endpoint and data flow testing
- End-to-End Testing: Complete user flow testing
- Performance Testing: Load speed and user experience testing
Testing Architecture Diagram
🔄 正在渲染 Mermaid 图表...
Unit Testing
Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { getViteConfig } from 'astro/config';
export default defineConfig(
getViteConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'dist/',
],
},
},
})
);
// src/test/setup.ts
import { vi } from 'vitest';
import '@testing-library/jest-dom';
// Mock browser APIs
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
Component Testing
// src/components/__tests__/BlogCard.test.ts
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/vue';
import BlogCard from '../BlogCard.vue';
const mockPost = {
id: 1,
title: 'Test Article Title',
excerpt: 'This is a test article excerpt',
publishDate: '2024-01-15',
author: 'Test Author',
tags: ['Vue', 'Astro', 'Testing'],
slug: 'test-post',
};
describe('BlogCard', () => {
it('should render article information correctly', () => {
render(BlogCard, {
props: { post: mockPost }
});
expect(screen.getByText(mockPost.title)).toBeInTheDocument();
expect(screen.getByText(mockPost.excerpt)).toBeInTheDocument();
expect(screen.getByText(mockPost.author)).toBeInTheDocument();
});
it('should render correct link', () => {
render(BlogCard, {
props: { post: mockPost }
});
const titleLink = screen.getByRole('link', { name: mockPost.title });
expect(titleLink).toHaveAttribute('href', `/blog/${mockPost.slug}`);
});
it('should display all tags', () => {
render(BlogCard, {
props: { post: mockPost }
});
mockPost.tags.forEach(tag => {
expect(screen.getByText(`#${tag}`)).toBeInTheDocument();
});
});
it('should format date display', () => {
render(BlogCard, {
props: { post: mockPost }
});
const formattedDate = new Date(mockPost.publishDate).toLocaleDateString('zh-CN');
expect(screen.getByText(formattedDate)).toBeInTheDocument();
});
it('should handle missing optional fields', () => {
const postWithoutOptionalFields = {
...mockPost,
tags: [],
};
render(BlogCard, {
props: { post: postWithoutOptionalFields }
});
expect(screen.getByText(mockPost.title)).toBeInTheDocument();
// Ensure it doesn't crash when there are no tags
expect(screen.queryByText('#')).not.toBeInTheDocument();
});
});
Utility Function Testing
// src/utils/__tests__/formatters.test.ts
import { describe, it, expect } from 'vitest';
import {
formatPrice,
formatDate,
slugify,
truncateText,
validateEmail,
} from '../formatters';
describe('formatters', () => {
describe('formatPrice', () => {
it('should format price correctly', () => {
expect(formatPrice(1234.56)).toBe('¥1,234.56');
expect(formatPrice(0)).toBe('¥0.00');
expect(formatPrice(999.9)).toBe('¥999.90');
});
it('should handle negative numbers', () => {
expect(formatPrice(-100)).toBe('-¥100.00');
});
it('should support custom currency symbols', () => {
expect(formatPrice(100, '$')).toBe('$100.00');
});
});
describe('formatDate', () => {
it('should format date strings', () => {
const date = '2024-01-15';
expect(formatDate(date, 'zh-CN')).toBe('2024/1/15');
});
it('should format Date objects', () => {
const date = new Date('2024-01-15');
expect(formatDate(date, 'zh-CN')).toBe('2024/1/15');
});
it('should support different locales', () => {
const date = '2024-01-15';
expect(formatDate(date, 'en-US')).toBe('1/15/2024');
});
});
describe('slugify', () => {
it('should convert text to URL-friendly slug', () => {
expect(slugify('Hello World')).toBe('hello-world');
expect(slugify('Vue.js Development Guide')).toBe('vue-js-development-guide');
});
it('should remove special characters', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
expect(slugify('Test@#$%^&*()')).toBe('test');
});
it('should handle consecutive spaces and separators', () => {
expect(slugify(' hello world ')).toBe('hello-world');
expect(slugify('hello---world')).toBe('hello-world');
});
});
describe('truncateText', () => {
it('should truncate long text', () => {
const longText = 'This is a very long text that should be truncated';
expect(truncateText(longText, 20)).toBe('This is a very long...');
});
it('should keep short text unchanged', () => {
const shortText = 'Short text';
expect(truncateText(shortText, 20)).toBe('Short text');
});
it('should support custom ellipsis', () => {
const text = 'This is a long text';
expect(truncateText(text, 10, ' →')).toBe('This is a →');
});
});
describe('validateEmail', () => {
it('should validate valid email addresses', () => {
expect(validateEmail('test@example.com')).toBe(true);
expect(validateEmail('user.name+tag@domain.co.uk')).toBe(true);
});
it('should reject invalid email addresses', () => {
expect(validateEmail('invalid-email')).toBe(false);
expect(validateEmail('@domain.com')).toBe(false);
expect(validateEmail('user@')).toBe(false);
expect(validateEmail('')).toBe(false);
});
});
});
Store Testing
// src/stores/__tests__/cart.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useCartStore } from '../cart';
const mockProduct = {
id: 1,
name: 'Test Product',
price: 99.99,
image: '/test-image.jpg',
};
describe('Cart Store', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('should initialize with empty cart', () => {
const cart = useCartStore();
expect(cart.items).toEqual([]);
expect(cart.totalItems).toBe(0);
expect(cart.totalPrice).toBe(0);
expect(cart.isEmpty).toBe(true);
});
it('should add item correctly', () => {
const cart = useCartStore();
cart.addItem(mockProduct);
expect(cart.items).toHaveLength(1);
expect(cart.items[0]).toEqual({ ...mockProduct, quantity: 1 });
expect(cart.totalItems).toBe(1);
expect(cart.totalPrice).toBe(99.99);
});
it('should increase quantity for existing items', () => {
const cart = useCartStore();
cart.addItem(mockProduct);
cart.addItem(mockProduct);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(2);
expect(cart.totalItems).toBe(2);
expect(cart.totalPrice).toBe(199.98);
});
it('should remove item correctly', () => {
const cart = useCartStore();
cart.addItem(mockProduct);
cart.removeItem(mockProduct.id);
expect(cart.items).toHaveLength(0);
expect(cart.isEmpty).toBe(true);
});
it('should update item quantity', () => {
const cart = useCartStore();
cart.addItem(mockProduct);
cart.updateQuantity(mockProduct.id, 3);
expect(cart.items[0].quantity).toBe(3);
expect(cart.totalItems).toBe(3);
});
it('should remove item when quantity is 0', () => {
const cart = useCartStore();
cart.addItem(mockProduct);
cart.updateQuantity(mockProduct.id, 0);
expect(cart.items).toHaveLength(0);
});
it('should clear cart', () => {
const cart = useCartStore();
cart.addItem(mockProduct);
cart.clearCart();
expect(cart.items).toHaveLength(0);
expect(cart.isOpen).toBe(false);
});
it('should format total price correctly', () => {
const cart = useCartStore();
cart.addItem(mockProduct);
cart.updateQuantity(mockProduct.id, 2);
expect(cart.formattedTotal).toBe('¥199.98');
});
});
Integration Testing
API Endpoint Testing
// src/pages/api/__tests__/posts.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { GET, POST } from '../posts.ts';
// Mock database
const mockPosts = [
{
id: 1,
title: 'Test Article 1',
content: 'Test Content 1',
author: 'test-user',
createdAt: '2024-01-15T00:00:00Z',
},
{
id: 2,
title: 'Test Article 2',
content: 'Test Content 2',
author: 'test-user',
createdAt: '2024-01-16T00:00:00Z',
},
];
// Mock database operations
vi.mock('../../../lib/database', () => ({
getAllPosts: vi.fn().mockResolvedValue(mockPosts),
createPost: vi.fn().mockImplementation((data) => ({
id: 3,
...data,
createdAt: new Date().toISOString(),
})),
}));
describe('Posts API', () => {
describe('GET /api/posts', () => {
it('should return all posts', async () => {
const request = new Request('http://localhost:3000/api/posts');
const response = await GET({ request, params: {}, locals: {} });
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toEqual(mockPosts);
});
it('should support pagination parameters', async () => {
const request = new Request('http://localhost:3000/api/posts?page=1&limit=1');
const response = await GET({ request, params: {}, locals: {} });
expect(response.status).toBe(200);
// Verify pagination logic
});
});
describe('POST /api/posts', () => {
it('should create new post', async () => {
const postData = {
title: 'New Article',
content: 'New Article Content',
};
const request = new Request('http://localhost:3000/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData),
});
const response = await POST({
request,
params: {},
locals: { user: { id: 'test-user' } },
});
expect(response.status).toBe(201);
const data = await response.json();
expect(data.title).toBe(postData.title);
expect(data.content).toBe(postData.content);
});
it('should validate request data', async () => {
const invalidData = {
title: '', // Empty title should be rejected
};
const request = new Request('http://localhost:3000/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(invalidData),
});
const response = await POST({
request,
params: {},
locals: { user: { id: 'test-user' } },
});
expect(response.status).toBe(400);
});
it('should require user authentication', async () => {
const postData = {
title: 'New Article',
content: 'New Article Content',
};
const request = new Request('http://localhost:3000/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData),
});
const response = await POST({
request,
params: {},
locals: {}, // No user information
});
expect(response.status).toBe(401);
});
});
});
End-to-End Testing
Playwright Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
User Flow Testing
// e2e/blog.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Blog Features', () => {
test('should browse article list', async ({ page }) => {
await page.goto('/blog');
// Check page title
await expect(page).toHaveTitle(/Blog/);
// Check article list
const articleCards = page.locator('.blog-card');
await expect(articleCards).toHaveCount(6); // Assuming 6 articles per page
// Check basic elements of each article card
const firstCard = articleCards.first();
await expect(firstCard.locator('h2')).toBeVisible();
await expect(firstCard.locator('.excerpt')).toBeVisible();
await expect(firstCard.locator('time')).toBeVisible();
});
test('should navigate to article detail page', async ({ page }) => {
await page.goto('/blog');
const firstArticleLink = page.locator('.blog-card h2 a').first();
const articleTitle = await firstArticleLink.textContent();
await firstArticleLink.click();
// Check if navigated to correct article page
await expect(page.locator('h1')).toHaveText(articleTitle!);
await expect(page.locator('.post-content')).toBeVisible();
});
test('should use search functionality', async ({ page }) => {
await page.goto('/blog');
const searchInput = page.locator('input[type="search"]');
await searchInput.fill('Vue');
await searchInput.press('Enter');
// Check search results
await expect(page.locator('.search-results')).toBeVisible();
await expect(page.locator('.blog-card')).toHaveCount(3); // Assuming 3 related articles
});
test('should filter articles by tags', async ({ page }) => {
await page.goto('/blog');
const tagFilter = page.locator('.tag-filter').first();
const tagName = await tagFilter.textContent();
await tagFilter.click();
// Check filter results
await expect(page.url()).toContain(`/blog/tags/${tagName?.replace('#', '')}`);
await expect(page.locator('.blog-card')).toHaveCountGreaterThan(0);
});
test('should support pagination navigation', async ({ page }) => {
await page.goto('/blog');
// Click next page
const nextButton = page.locator('.pagination a[aria-label="Next"]');
if (await nextButton.isVisible()) {
await nextButton.click();
// Check URL change
await expect(page.url()).toContain('/blog/page/2');
// Check page content updated
await expect(page.locator('.blog-card')).toHaveCountGreaterThan(0);
}
});
});
Shopping Cart Feature Testing
// e2e/shopping-cart.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Shopping Cart Features', () => {
test('should add product to cart', async ({ page }) => {
await page.goto('/products/1');
// Check product page
await expect(page.locator('h1')).toBeVisible();
await expect(page.locator('.price')).toBeVisible();
// Add to cart
const addToCartButton = page.locator('button[data-testid="add-to-cart"]');
await addToCartButton.click();
// Check cart icon updated
const cartBadge = page.locator('.cart-badge');
await expect(cartBadge).toHaveText('1');
// Check success message
await expect(page.locator('.notification')).toContainText('Added to cart');
});
test('should manage products in cart', async ({ page }) => {
// First add product
await page.goto('/products/1');
await page.locator('button[data-testid="add-to-cart"]').click();
// Open cart
await page.locator('.cart-button').click();
// Check cart content
await expect(page.locator('.cart-sidebar')).toBeVisible();
await expect(page.locator('.cart-item')).toHaveCount(1);
// Increase quantity
await page.locator('.quantity-btn[aria-label="Increase"]').click();
await expect(page.locator('.quantity')).toHaveText('2');
// Check total price updated
await expect(page.locator('.total-price')).toContainText('¥199.98');
// Remove product
await page.locator('.remove-button').click();
await expect(page.locator('.empty-cart')).toBeVisible();
});
test('should complete checkout process', async ({ page }) => {
// Add product and proceed to checkout
await page.goto('/products/1');
await page.locator('button[data-testid="add-to-cart"]').click();
await page.locator('.cart-button').click();
await page.locator('button:has-text("Checkout")').click();
// Check checkout page
await expect(page).toHaveURL('/checkout');
await expect(page.locator('.checkout-form')).toBeVisible();
// Fill shipping information
await page.fill('input[name="name"]', 'John Doe');
await page.fill('input[name="phone"]', '13800138000');
await page.fill('textarea[name="address"]', '123 Test Street, Beijing');
// Select payment method
await page.click('input[value="alipay"]');
// Submit order
await page.click('button[type="submit"]');
// Check success page
await expect(page).toHaveURL(/\/order\/success/);
await expect(page.locator('.success-message')).toBeVisible();
});
});
Performance Testing
Lighthouse CI Configuration
// .lighthouserc.json
{
"ci": {
"collect": {
"url": [
"http://localhost:3000/",
"http://localhost:3000/blog",
"http://localhost:3000/products"
],
"startServerCommand": "npm run preview",
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["warn", {"minScore": 0.9}],
"categories:accessibility": ["error", {"minScore": 0.9}],
"categories:best-practices": ["warn", {"minScore": 0.9}],
"categories:seo": ["error", {"minScore": 0.9}]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
Performance Benchmark Testing
// src/test/performance.test.ts
import { describe, it, expect } from 'vitest';
import { performance } from 'perf_hooks';
describe('Performance Benchmarks', () => {
it('slugify function should execute within reasonable time', () => {
const longText = 'A'.repeat(1000);
const iterations = 1000;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
slugify(longText);
}
const end = performance.now();
const duration = end - start;
// 1000 calls should complete within 100ms
expect(duration).toBeLessThan(100);
});
it('large dataset sorting should maintain good performance', () => {
const largeDataset = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
score: Math.random(),
}));
const start = performance.now();
const sorted = largeDataset.sort((a, b) => b.score - a.score);
const end = performance.now();
const duration = end - start;
expect(duration).toBeLessThan(50); // Complete sorting within 50ms
expect(sorted[0].score).toBeGreaterThanOrEqual(sorted[1].score);
});
});
CI/CD Configuration
GitHub Actions Workflow
# .github/workflows/test.yml
name: Test and Quality Assurance
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 }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run type checking
run: npm run typecheck
- name: Run unit tests
run: npm run test:unit
- name: Run coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Build application
run: npm run build
- name: Run Playwright tests
run: npm run test:e2e
- name: Upload test results
uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: playwright-report/
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli@0.12.x
lhci autorun
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run security audit
run: npm audit --audit-level=high
- name: Run dependency check
uses: dependency-check/Dependency-Check_Action@main
with:
project: 'astro-app'
path: '.'
format: 'ALL'
- name: Upload security results
uses: actions/upload-artifact@v3
with:
name: dependency-check-report
path: reports/
Code Quality Configuration
// package.json scripts
{
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"test": "vitest",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"lint": "eslint . --ext .js,.ts,.vue,.astro",
"lint:fix": "eslint . --ext .js,.ts,.vue,.astro --fix",
"typecheck": "astro check && tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lighthouse": "lhci autorun"
}
}
// .eslintrc.js
module.exports = {
extends: [
'eslint:recommended',
'@typescript-eslint/recommended',
'plugin:astro/recommended',
'plugin:vue/vue3-recommended',
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
// Custom rules
'no-console': 'warn',
'no-debugger': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'vue/no-unused-vars': 'error',
},
overrides: [
{
files: ['*.astro'],
parser: 'astro-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
extraFileExtensions: ['.astro'],
},
},
],
};
Key Takeaways
- Layered Testing: Unit testing, integration testing, and end-to-end testing each serve their purpose
- Automated Process: CI/CD ensures code quality and deployment safety
- Performance Monitoring: Continuously monitor application performance metrics
- Code Quality: Linting, formatting, and type checking ensure code standards
- Security Checks: Dependency audits and security scans prevent potential risks
Important Notes
- Balance test coverage with completeness and maintenance costs
- E2E tests take longer to execute and require well-designed test cases
- Performance tests should be conducted in stable environments
- CI/CD pipelines should consider execution efficiency and resource consumption