自动化测试教程 🔍

1. 单元测试编写 ✅

1.1 unittest框架介绍

Python的unittest框架提供了编写和运行测试的完整工具集:

import unittest

class TestStringMethods(unittest.TestCase):
    def setUp(self):
        """测试前的准备工作"""
        self.test_string = "hello world"
    
    def test_upper(self):
        """测试大写转换"""
        self.assertEqual(self.test_string.upper(), "HELLO WORLD")
    
    def test_split(self):
        """测试字符串分割"""
        self.assertEqual(self.test_string.split(), ['hello', 'world'])
    
    def tearDown(self):
        """测试后的清理工作"""
        pass

if __name__ == '__main__':
    unittest.main()

1.2 测试用例编写原则

  1. FIRST原则

    • Fast (快速): 测试应该快速执行
    • Independent (独立): 测试之间不应相互依赖
    • Repeatable (可重复): 在任何环境下都能得到相同结果
    • Self-validating (自验证): 测试应该自动输出结果
    • Timely (及时): 测试应该在编写代码之前或同时编写
  2. 单一职责

    • 每个测试用例只测试一个功能点
    • 保持测试用例的独立性
    • 避免测试用例之间的依赖
  3. 命名规范

    def test_should_create_user_when_valid_data_provided():
        # 测试用例名称应该清晰地表达:
        # - 被测试的场景(should_create_user)
        # - 触发条件(when_valid_data_provided)
        pass
    
    def test_should_raise_error_when_username_exists():
        # 明确指出期望的行为和触发条件
        pass
    
  4. 测试边界条件

    def test_user_password_validation():
        # 测试最小长度边界
        self.assertFalse(validate_password("ab12"))  # 5个字符以下
        self.assertTrue(validate_password("abc123"))  # 6个字符
        
        # 测试最大长度边界
        long_password = "a" * 100
        self.assertFalse(validate_password(long_password))
        
        # 测试特殊情况
        self.assertFalse(validate_password(""))  # 空密码
        self.assertFalse(validate_password(None))  # None值
    
  5. AAA模式

    def test_user_registration(self):
        # Arrange(准备)
        user_data = {
            'username': 'testuser',
            'email': 'test@example.com',
            'password': 'secure123'
        }
        
        # Act(执行)
        response = self.client.post('/register', data=user_data)
        
        # Assert(断言)
        self.assertEqual(response.status_code, 201)
        self.assertTrue(User.query.filter_by(username='testuser').first())
    

1.3 Mock对象使用

from unittest.mock import Mock, patch

class TestUserService:
    @patch('app.services.database')
    def test_get_user(self, mock_db):
        # 配置mock对象
        mock_db.query.return_value = {
            'id': 1,
            'name': 'Test User'
        }
        
        # 执行测试
        user_service = UserService()
        result = user_service.get_user(1)
        
        # 验证结果
        self.assertEqual(result['name'], 'Test User')
        mock_db.query.assert_called_once_with(1)

2. 自动化测试框架 🔄

2.1 pytest框架高级特性

  1. 强大的夹具系统
import pytest
from datetime import datetime, timedelta

# 作用域为session的夹具,整个测试会话期间只执行一次
@pytest.fixture(scope="session")
def db_connection():
    # 建立数据库连接
    connection = create_db_connection()
    yield connection
    # 测试结束后关闭连接
    connection.close()

# 作用域为function的夹具,每个测试函数执行前都会重新创建
@pytest.fixture(scope="function")
def temp_user(db_connection):
    # 创建临时用户
    user = User(username=f"test_user_{datetime.now().timestamp()}")
    db_connection.save(user)
    yield user
    # 测试后清理用户数据
    db_connection.delete(user)

# 参数化的夹具
@pytest.fixture(params=[
    ("standard_user", ["read"]),
    ("admin_user", ["read", "write", "delete"]),
    ("guest_user", [])
])
def user_role(request):
    return request.param
  1. 强大的断言机制
def test_advanced_assertions(temp_user):
    # 精确匹配
    assert temp_user.username.startswith("test_user_")
    
    # 浮点数比较
    assert pytest.approx(0.1 + 0.2) == 0.3
    
    # 异常断言
    with pytest.raises(ValueError) as excinfo:
        temp_user.set_age(-1)
    assert "Age cannot be negative" in str(excinfo.value)
    
    # 警告断言
    with pytest.warns(DeprecationWarning):
        temp_user.old_method()
        
    # 对象属性断言
    assert hasattr(temp_user, 'email')
  1. 跳过和标记测试
@pytest.mark.skip(reason="feature not implemented yet")
def test_future_feature():
    pass

@pytest.mark.skipif(sys.version_info < (3, 9), 
                   reason="requires python3.9 or higher")
def test_new_feature():
    pass

@pytest.mark.slow
def test_slow_operation():
    # 耗时操作测试
    pass

# 在命令行中运行特定标记的测试
# pytest -v -m "not slow"
import pytest
from app.calculator import Calculator

@pytest.fixture
def calculator():
    """测试夹具:创建计算器实例"""
    return Calculator()

def test_addition(calculator):
    """测试加法功能"""
    assert calculator.add(2, 3) == 5
    assert calculator.add(-1, 1) == 0

def test_division(calculator):
    """测试除法功能"""
    assert calculator.divide(6, 2) == 3
    with pytest.raises(ValueError):
        calculator.divide(1, 0)

@pytest.mark.parametrize("input,expected", [
    ((4, 2), 2),
    ((9, 3), 3),
    ((15, 5), 3),
])
def test_division_parameters(calculator, input, expected):
    """参数化测试"""
    assert calculator.divide(*input) == expected

2.2 测试覆盖率分析

# coverage配置文件:.coveragerc
[run]
source = app
omit = tests/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise NotImplementedError
    if __name__ == "__main__":
    pass

[html]
directory = coverage_html

运行覆盖率测试:

coverage run -m pytest
coverage report
coverage html

3. 持续集成与部署实践 🔁

3.1 CI/CD最佳实践

  1. 分支策略
main (生产环境)
  ↑
develop (开发环境)
  ↑
feature/* (功能分支)
hotfix/* (紧急修复)
release/* (发布准备)
  1. 构建矩阵
# .github/workflows/test-matrix.yml
jobs:
  test:
    strategy:
      matrix:
        python-version: [3.8, 3.9, 3.10]
        os: [ubuntu-latest, windows-latest, macos-latest]
        database: [sqlite, mysql, postgresql]
        exclude:
          - os: windows-latest
            database: postgresql
    
    runs-on: ${{ matrix.os }}
    
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: test_db
        ports:
          - 3306:3306
      
      postgres:
        image: postgres:13
        env:
          POSTGRES_PASSWORD: password
          POSTGRES_DB: test_db
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v2
      
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v2
        with:
          python-version: ${{ matrix.python-version }}
      
      - name: Run Tests
        env:
          DB_TYPE: ${{ matrix.database }}
        run: |
          pytest --cov=app tests/
  1. 质量控制
# .github/workflows/quality.yml
jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Set up Python
        uses: actions/setup-python@v2
        
      - name: Install tools
        run: |
          pip install black flake8 mypy bandit safety
          
      - name: Code formatting
        run: black --check .
        
      - name: Linting
        run: flake8 .
        
      - name: Type checking
        run: mypy .
        
      - name: Security check
        run: |
          bandit -r .
          safety check

### 3.1 CI/CD配置

```yaml
# .github/workflows/python-app.yml
name: Python application

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    
    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: '3.9'
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install pytest pytest-cov
    
    - name: Run tests
      run: |
        pytest --cov=app tests/
        
    - name: Upload coverage reports
      uses: codecov/codecov-action@v3

3.2 自动化部署流程

  1. 构建阶段

    • 代码检出
    • 安装依赖
    • 运行测试
    • 构建产物
  2. 部署阶段

    • 环境配置
    • 数据库迁移
    • 服务部署
    • 健康检查

4. 实战案例:自动化测试套件 🎯

让我们为博客系统创建完整的测试套件:

import unittest
from flask_testing import TestCase
from app import create_app, db
from app.models import User, Post

class TestConfig:
    TESTING = True
    SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
    WTF_CSRF_ENABLED = False

class BlogTestCase(TestCase):
    def create_app(self):
        """创建测试应用"""
        app = create_app(TestConfig)
        return app
    
    def setUp(self):
        """测试准备"""
        db.create_all()
        self.create_test_user()
    
    def tearDown(self):
        """测试清理"""
        db.session.remove()
        db.drop_all()
    
    def create_test_user(self):
        """创建测试用户"""
        user = User(username='testuser', email='test@example.com')
        user.set_password('password123')
        db.session.add(user)
        db.session.commit()
        self.test_user = user
    
    def test_user_registration(self):
        """测试用户注册"""
        response = self.client.post('/auth/register', data={
            'username': 'newuser',
            'email': 'new@example.com',
            'password': 'newpass123',
            'password2': 'newpass123'
        })
        self.assertEqual(response.status_code, 302)
        user = User.query.filter_by(username='newuser').first()
        self.assertIsNotNone(user)
    
    def test_post_creation(self):
        """测试文章创建"""
        with self.client:
            self.client.post('/auth/login', data={
                'username': 'testuser',
                'password': 'password123'
            })
            response = self.client.post('/post/create', data={
                'title': 'Test Post',
                'content': 'Test Content'
            })
            self.assertEqual(response.status_code, 302)
            post = Post.query.filter_by(title='Test Post').first()
            self.assertIsNotNone(post)
            self.assertEqual(post.author, self.test_user)

class APITestCase(TestCase):
    def test_get_posts(self):
        """测试获取文章列表API"""
        response = self.client.get('/api/posts')
        self.assertEqual(response.status_code, 200)
        self.assertTrue(isinstance(response.json, list))
    
    def test_create_post(self):
        """测试创建文章API"""
        response = self.client.post('/api/posts', json={
            'title': 'API Test Post',
            'content': 'Content from API test'
        })
        self.assertEqual(response.status_code, 201)
        self.assertEqual(response.json['title'], 'API Test Post')

def run_tests():
    """运行所有测试"""
    unittest.main()

if __name__ == '__main__':
    run_tests()

5. 高级测试技术 🚀

5.1 性能测试

import pytest
from locust import HttpUser, task, between

class WebsiteUser(HttpUser):
    wait_time = between(1, 5)  # 用户等待时间1-5秒
    
    @task(2)
    def view_posts(self):
        self.client.get("/posts")
    
    @task(1)
    def create_post(self):
        self.client.post("/posts/create", json={
            "title": "New Post",
            "content": "Content"
        })

# 命令行运行:locust -f locustfile.py --host=http://localhost:8000

5.2 安全性测试

import pytest
from bs4 import BeautifulSoup
import re

class TestSecurity:
    def test_xss_prevention(self, client):
        """测试XSS防护"""
        malicious_input = "<script>alert('xss')</script>"
        response = client.post('/comment/create', data={
            'content': malicious_input
        })
        
        soup = BeautifulSoup(response.data, 'html.parser')
        # 验证脚本标签被转义
        assert "<script>" not in str(soup)
    
    def test_sql_injection_prevention(self, client):
        """测试SQL注入防护"""
        malicious_input = "' OR '1'='1"
        response = client.get(f'/users/search?q={malicious_input}')
        assert response.status_code != 500
    
    def test_csrf_protection(self, client):
        """测试CSRF保护"""
        # 获取CSRF令牌
        response = client.get('/post/create')
        soup = BeautifulSoup(response.data, 'html.parser')
        csrf_token = soup.find('input', {'name': 'csrf_token'})['value']
        
        # 验证无令牌请求被拒绝
        response_without_token = client.post('/post/create', data={
            'title': 'Test Post'
        })
        assert response_without_token.status_code == 400
        
        # 验证有令牌请求被接受
        response_with_token = client.post('/post/create', data={
            'title': 'Test Post',
            'csrf_token': csrf_token
        })
        assert response_with_token.status_code == 302

5.3 并发测试

import threading
import queue
import pytest

def test_concurrent_access(app, client):
    """测试并发访问"""
    num_threads = 10
    results = queue.Queue()
    
    def worker():
        try:
            response = client.post('/api/resource', json={
                'name': 'test_resource'
            })
            results.put(('success', response.status_code))
        except Exception as e:
            results.put(('error', str(e)))
    
    # 创建多个线程同时访问
    threads = []
    for _ in range(num_threads):
        t = threading.Thread(target=worker)
        t.start()
        threads.append(t)
    
    # 等待所有线程完成
    for t in threads:
        t.join()
    
    # 分析结果
    success_count = 0
    error_count = 0
    while not results.empty():
        status, _ = results.get()
        if status == 'success':
            success_count += 1
        else:
            error_count += 1
    
    # 验证结果
    assert success_count == 1  # 只有一个请求应该成功
    assert error_count == num_threads - 1  # 其他请求应该失败

测试套件特点:

  1. 完整的测试覆盖

    • 用户认证测试
    • 文章管理测试
    • API接口测试
    • 数据库操作测试
  2. 测试环境隔离

    • 使用内存数据库
    • 独立的测试配置
    • 每次测试后清理数据
  3. 持续集成支持

    • GitHub Actions集成
    • 自动运行测试
    • 覆盖率报告生成
  4. 测试辅助工具

    • 测试夹具(Fixtures)
    • Mock对象
    • 参数化测试

扩展建议:

  1. 添加性能测试
  2. 实现端到端测试
  3. 添加负载测试
  4. 实现安全性测试
  5. 添加接口文档测试
  6. 实现并发测试
  7. 添加回归测试套件

这个实战案例展示了如何为Web应用构建完整的测试体系,涵盖了自动化测试中的各个重要概念和实践。你可以基于这个框架继续扩展更多测试场景。


如果你觉得这篇文章有帮助,欢迎点赞转发,也期待在评论区看到你的想法和建议!👇

咱们下一期见!

Logo

一站式 AI 云服务平台

更多推荐