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
  1. Layered Testing: Unit testing, integration testing, and end-to-end testing each serve their purpose
  2. Automated Process: CI/CD ensures code quality and deployment safety
  3. Performance Monitoring: Continuously monitor application performance metrics
  4. Code Quality: Linting, formatting, and type checking ensure code standards
  5. 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