1. 项目概述与核心价值

最近在带新人做软件测试的实战项目,发现很多同学对“如何从一个零散的项目标题,落地为一套完整的、可交付的测试资产”这个过程感到迷茫。正好,手头有一个非常经典的实战案例——“ECShop电子商务系统软件测试作业”。这个标题看似简单,只列出了几项交付物:搭建文档、接口测试用例、接口文档和测试脚本。但如果你真的以为这只是把四个文件拼凑起来,那就大错特错了。这背后,其实是一套完整的、从环境部署到自动化验证的软件测试工程化实践。对于想入行软件测试,或者希望从功能测试转向测试开发的同学来说,把这个项目吃透,价值远超做几十道八股文面试题。

ECShop作为一个开源的老牌电商系统,其架构(典型的LAMP/ LNMP)和功能模块(商品、订单、会员、支付)在电商领域极具代表性。以它作为测试对象,你几乎能接触到Web系统测试的所有核心场景:环境搭建与配置、前后端功能测试、数据库验证,以及重头戏——接口测试。而“接口测试”正是当前企业招聘时非常看重的技能点,从Postman、Apifox的手工测试,到使用Python+Requests或JMeter编写自动化脚本,再到思考如何与CI/CD流水线集成,这一条技术链正是本次项目的精髓所在。所以,这个项目不只是完成作业,更是你构建个人技术栈、打造求职项目经验的绝佳机会。接下来,我将以从业者的视角,为你彻底拆解如何高质量地完成这个项目,其中会包含大量在官方文档里不会写的“踩坑”经验和工程化思考。

2. 环境搭建:从零构建可测试的ECShop系统

2.1 技术栈选型与准备工作

在开始写任何测试用例之前,一个稳定、干净的测试环境是基石。ECShop虽然老旧,但其环境依赖非常明确。为了避免后续出现各种诡异问题,我强烈建议使用虚拟化或容器化技术来隔离环境。

我的选择是Docker Compose 。原因有三:第一,它能把PHP、MySQL、Nginx等组件及其版本、配置全部固化在 docker-compose.yml 文件中,实现环境即代码,在任何机器上都能一键复现。第二,完全与宿主机环境隔离,测试结束后销毁容器,不会留下任何垃圾。第三,非常适合模拟不同的系统配置(如切换PHP版本)进行兼容性测试。

你需要准备以下工具:

  1. Docker Desktop :用于运行容器。确保在安装后开启WSL2或Hyper-V支持。
  2. Git :用于克隆ECShop源码。
  3. 一款趁手的IDE :VSCode或JetBrains系列均可,用于编写脚本和文档。
  4. 浏览器及开发者工具 :Chrome或Edge,用于前端操作和抓取接口。

注意 :切勿在个人常用的开发机或服务器上直接安装LAMP环境来部署ECShop,版本冲突、依赖污染会让你在后续步骤中痛苦不堪。Docker是我们的“安全沙盒”。

2.2 基于Docker的ECShop部署实操

这里我分享一个经过验证的 docker-compose.yml 配置,它包含了ECShop v4.1版本所需的所有服务。

version: '3.8'
services:
  mysql:
    image: mysql:5.7
    container_name: ecshop-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root123456
      MYSQL_DATABASE: ecshop
      MYSQL_USER: ecshop_user
      MYSQL_PASSWORD: ecshop_pass
    volumes:
      - ./mysql_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "3307:3306"
    networks:
      - ecshop-network

  php:
    build: ./php
    container_name: ecshop-php
    volumes:
      - ./ecshop:/var/www/html
      - ./php/php.ini:/usr/local/etc/php/php.ini
    depends_on:
      - mysql
    networks:
      - ecshop-network

  nginx:
    image: nginx:1.20-alpine
    container_name: ecshop-nginx
    ports:
      - "8080:80"
    volumes:
      - ./ecshop:/var/www/html
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - php
    networks:
      - ecshop-network

networks:
  ecshop-network:
    driver: bridge

关键配置解析与避坑指南:

  1. MySQL服务

    • image: mysql:5.7 :ECShop对MySQL 8.0+的兼容性可能有问题,5.7是最稳妥的选择。
    • 端口映射为 3307:3306 :避免与宿主机可能已存在的MySQL服务(默认3306)冲突。后续测试脚本连接数据库时,主机地址应为 localhost:3307
    • volumes 中的 init.sql :这是一个初始化脚本,用于创建数据库和用户。内容如下:
      CREATE DATABASE IF NOT EXISTS `ecshop` DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;
      GRANT ALL PRIVILEGES ON `ecshop`.* TO 'ecshop_user'@'%' IDENTIFIED BY 'ecshop_pass';
      FLUSH PRIVILEGES;
      
  2. PHP服务 :这里我们选择自定义构建。在项目根目录创建 php/Dockerfile

    FROM php:7.4-fpm
    # 安装ECShop必需的扩展
    RUN docker-php-ext-install mysqli pdo_mysql gd
    RUN apt-get update && apt-get install -y libpng-dev libjpeg-dev libfreetype6-dev
    RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
        && docker-php-ext-install -j$(nproc) gd
    
    • 为什么选PHP 7.4?这是兼顾ECShop兼容性和现代PHP特性的一个平衡点。务必安装 gd 扩展,否则后台验证码无法显示。
    • 将自定义的 php.ini (如调整上传文件大小、内存限制)挂载到容器中。
  3. Nginx服务 :配置 nginx/default.conf ,这是关键的一步,配置错误会导致访问404或PHP文件被下载。

    server {
        listen 80;
        server_name localhost;
        root /var/www/html;
        index index.php index.html index.htm;
    
        location / {
            try_files $uri $uri/ /index.php?$query_string;
        }
    
        location ~ \.php$ {
            fastcgi_pass php:9000; # 注意这里指向的是php服务名和端口
            fastcgi_index index.php;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            include fastcgi_params;
        }
    }
    
    • fastcgi_pass php:9000; :这里的 php 是Docker Compose网络中的服务名,Docker会将其解析为PHP容器的IP。这是容器间通信的标准方式,比用IP地址更稳定。

部署步骤:

  1. 在项目根目录执行 docker-compose up -d
  2. 访问 http://localhost:8080/install ,按照ECShop的安装向导完成安装。数据库主机填写 mysql (同样是服务名),端口 3306 (容器内端口)。
  3. 安装成功后,访问 http://localhost:8080 http://localhost:8080/admin 确认前后台可正常访问。

实操心得 :安装完成后,务必第一时间备份 /ecshop/data/config.php 和数据库。这是你的测试基准环境。任何破坏性测试(如压力测试、异常数据测试)前,都应先恢复到这个快照。

3. 接口文档分析与测试用例设计

3.1 逆向工程:如何获取与分析接口

ECShop作为传统PHP项目,通常没有提供标准的Swagger或OpenAPI接口文档。我们的“接口文档”需要自己通过分析来生成。这是测试工程师非常重要的能力——在没有文档的情况下摸清系统脉络。

方法一:浏览器开发者工具抓包(最直接)

  1. 打开Chrome开发者工具(F12),切换到 Network(网络) 标签页。
  2. 勾选 Preserve log(保留日志) ,防止页面跳转后请求记录被清空。
  3. 在ECShop前台进行关键操作:登录、搜索商品、加入购物车、下单、支付(模拟)。
  4. 观察捕获到的请求,重点关注 XHR Fetch 类型的请求,这些通常是API接口。例如:
    • user.php?act=login (POST) - 用户登录
    • flow.php?step=add_to_cart (POST) - 添加购物车
    • order.php?act=submit (POST) - 提交订单

方法二:分析源代码(最彻底) 直接阅读 /api/ /includes/ 目录下的PHP文件,特别是 cls_json.php api 开头的文件。ECShop的API通常以 api.php?act=xxx 的形式调用,参数通过 POST GET 传递,返回JSON格式数据。例如,查看 api/client/api.php ,你能找到所有客户端API的定义。

方法三:使用代理工具(最专业) 使用Fiddler或Charles这类抓包工具,可以捕获包括移动端在内的所有HTTP/HTTPS流量,并进行重放和篡改测试,对于分析接口行为非常高效。

生成你的接口文档 : 将分析结果整理成一份Markdown或Excel表格。一份合格的接口文档应包含:

  • 接口名称 :如“用户登录接口”
  • 请求URL http://localhost:8080/user.php
  • 请求方法 POST
  • 请求参数
    参数名 类型 是否必填 描述 示例
    act String 操作类型 login
    username String 用户名 testuser
    password String 密码 md5(‘123456’)
    remember Int 记住登录 1
  • 响应示例(成功)
    {
        "code": 1,
        "message": "登录成功",
        "data": {
            "user_id": 5,
            "user_name": "testuser"
        }
    }
    
  • 响应示例(失败)
    {
        "code": 0,
        "message": "用户名或密码错误"
    }
    
  • 备注 :如加密方式(ECShop密码常用MD5)、Session依赖等。

3.2 设计高覆盖率的接口测试用例

有了接口文档,就可以设计测试用例了。切忌只测“正常流”。一个好的测试用例集应该像一张网,覆盖各种场景。我们可以从以下几个维度设计:

1. 功能维度(针对单个接口)

  • 正向用例 :使用正确的参数,验证接口功能是否正常。这是最基本的。
  • 边界值分析 :针对数字、长度等参数。例如,用户名字段长度边界是3-20位,那么测试用例就应包括:2位(失败)、3位(成功)、20位(成功)、21位(失败)。
  • 异常参数
    • 必填参数缺失 :不传 username password
    • 参数类型错误 remember 传字符串 “true” 而非数字 1
    • 参数格式错误 :邮箱字段传入非法字符串。
    • SQL注入/XSS尝试 :在参数中传入 ‘ or ‘1’=‘1 <script>alert(1)</script> ,验证系统是否有防护。
  • 业务规则验证 :例如,登录接口的“记住我”功能是否真的延长了Session时间;添加购物车时,库存不足是否被正确处理。

2. 流程维度(串联多个接口) 电商核心流程: 注册/登录 -> 浏览商品 -> 加入购物车 -> 修改购物车 -> 生成订单 -> 支付 -> 查看订单状态 。我们需要设计端到端的流程用例,确保接口之间的数据传递和状态变更正确。例如,下单接口( order.php?act=submit )依赖购物车接口( flow.php?step=cart )产生的临时购物车ID。

3. 安全与性能维度

  • 安全 :重复提交订单(幂等性)、越权访问(尝试修改他人订单)、敏感信息泄露(响应中是否包含明文密码)。
  • 性能 :使用JMeter或 locust 对关键接口(如商品列表查询、下单接口)进行压力测试,评估响应时间和吞吐量。

用例管理建议 : 使用Excel或专业的测试管理工具(如Tapd、禅道)来管理用例。表格应包含:用例ID、模块、接口名称、用例标题、前置条件、请求方法/URL、请求头、请求参数、预期响应状态码、预期响应体、实际结果、测试人员、测试日期等字段。清晰的用例是后续编写自动化脚本的蓝图。

4. 接口测试脚本开发实战

4.1 工具选型:Postman vs. 代码脚本

对于这个项目,我建议采用 “Postman(或Apifox)用于调试与文档协作 + Python + Requests + Pytest用于自动化脚本” 的组合拳。

  • Postman/Apifox :图形化界面友好,非常适合在测试设计阶段快速调试接口、构造各种参数、查看响应。它们的环境变量、集合运行、数据驱动测试功能也很强大,可以作为轻量级的自动化工具。但对于需要复杂逻辑(如数据库校验、加解密、集成到CI/CD)的场景,代码脚本更灵活。
  • Python + Requests + Pytest :这是目前业界主流的接口自动化测试框架组合。Requests库简洁强大,Pytest提供了丰富的夹具(fixture)、参数化、断言和插件生态,测试报告美观。代码易于版本管理(Git),也方便与Jenkins、GitLab CI等工具集成。

为什么选择Python而不是JMeter? JMeter在性能测试领域是王者,但对于复杂的业务逻辑断言、数据库查询、动态参数处理,其BeanShell或JSR223脚本的编写和调试体验远不如Python友好。我们的项目重点是功能接口测试,Python脚本的可读性和可维护性更高。

4.2 基于Pytest的自动化测试框架搭建

让我们搭建一个结构清晰、易于维护的测试框架。

项目目录结构

ecshop_api_test/
├── conftest.py          # Pytest全局配置、夹具定义
├── requirements.txt     # 项目依赖
├── config/             # 配置文件
│   └── config.py       # 环境配置(测试URL、数据库连接等)
├── common/             # 公共模块
│   ├── __init__.py
│   ├── request_client.py # 封装的Requests客户端
│   └── db_client.py    # 数据库操作封装
├── test_data/          # 测试数据文件
│   ├── user_data.yaml  # 用户相关测试数据
│   └── order_data.yaml
├── test_cases/         # 测试用例目录
│   ├── __init__.py
│   ├── test_user.py    # 用户模块测试
│   └── test_order.py   # 订单模块测试
└── reports/            # 测试报告目录(自动生成)

1. 核心模块封装 ( common/request_client.py ) 不要在每个测试用例里都写 requests.post() ,封装一个客户端,统一处理日志、异常和基础断言。

import requests
import logging
from typing import Any, Dict, Optional

class ApiClient:
    def __init__(self, base_url: str):
        self.base_url = base_url.rstrip('/')
        self.session = requests.Session()
        self.logger = logging.getLogger(__name__)

    def request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
        url = f"{self.base_url}/{endpoint.lstrip('/')}"
        self.logger.info(f"Request: {method} {url}")
        self.logger.debug(f"Request kwargs: {kwargs}")

        try:
            resp = self.session.request(method, url, **kwargs)
            self.logger.info(f"Response Status: {resp.status_code}")
            self.logger.debug(f"Response Body: {resp.text}")
        except requests.exceptions.RequestException as e:
            self.logger.error(f"Request failed: {e}")
            raise

        return resp

    def post_form(self, endpoint: str, data: Dict[str, Any]) -> requests.Response:
        """针对ECShop常见表单提交的封装"""
        headers = {'Content-Type': 'application/x-www-form-urlencoded'}
        return self.request('POST', endpoint, data=data, headers=headers)

    def assert_status_code(self, response: requests.Response, expected_code: int):
        assert response.status_code == expected_code, \
            f"Expected status {expected_code}, but got {response.status_code}. Response: {response.text}"

2. 测试用例编写示例 ( test_cases/test_user.py ) 使用Pytest的夹具来管理测试前后的动作,如登录获取Token。

import pytest
from common.request_client import ApiClient
from config.config import TEST_CONFIG

class TestUserApi:
    @pytest.fixture(scope="class")
    def api_client(self):
        """返回一个配置好基础URL的API客户端"""
        return ApiClient(TEST_CONFIG['base_url'])

    @pytest.fixture
    def login_session(self, api_client):
        """登录并返回一个已登录的session客户端,用于需要登录态的测试"""
        client = ApiClient(TEST_CONFIG['base_url'])
        login_data = {
            'act': 'login',
            'username': TEST_CONFIG['test_user']['username'],
            'password': TEST_CONFIG['test_user']['password_md5'] # ECShop密码常为MD5
        }
        resp = client.post_form('user.php', data=login_data)
        # 假设登录成功会在响应头或Cookie中返回session信息,这里需要根据实际情况处理
        # 例如,ECShop可能使用Cookie: ECS_ID
        assert resp.status_code == 200
        assert '登录成功' in resp.text
        return client # 返回已携带登录态Cookie的client

    def test_login_success(self, api_client):
        """测试用户登录成功"""
        data = {
            'act': 'login',
            'username': 'correct_user',
            'password': 'e10adc3949ba59abbe56e057f20f883e' # '123456'的MD5
        }
        resp = api_client.post_form('user.php', data=data)
        api_client.assert_status_code(resp, 200)
        resp_json = resp.json()
        assert resp_json['code'] == 1
        assert 'user_id' in resp_json.get('data', {})

    @pytest.mark.parametrize("username, password, expected_msg", [
        ("wrong_user", "e10adc3949ba59abbe56e057f20f883e", "用户名或密码错误"),
        ("correct_user", "wrong_md5", "用户名或密码错误"),
        ("", "e10adc3949ba59abbe56e057f20f883e", "用户名不能为空"), # 假设的返回
    ])
    def test_login_failure(self, api_client, username, password, expected_msg):
        """参数化测试登录失败场景"""
        data = {'act': 'login', 'username': username, 'password': password}
        resp = api_client.post_form('user.php', data=data)
        api_client.assert_status_code(resp, 200)
        assert expected_msg in resp.text

    def test_add_to_cart_requires_login(self, api_client, login_session):
        """测试添加购物车需要登录态:未登录应失败,登录后应成功"""
        # 1. 使用未登录的客户端尝试添加购物车
        data = {'act': 'add_to_cart', 'goods_id': 1, 'number': 1}
        resp = api_client.post_form('flow.php', data=data)
        # 根据ECShop逻辑,可能返回错误码或跳转到登录页
        assert resp.status_code != 200 or '请先登录' in resp.text

        # 2. 使用已登录的客户端尝试添加购物车
        resp_logged = login_session.post_form('flow.php', data=data)
        login_session.assert_status_code(resp_logged, 200)
        assert '添加成功' in resp_logged.text

3. 数据库校验的集成 真正的自动化测试,不仅要看接口返回,还要验证数据是否正确地落库了。

# common/db_client.py
import pymysql
from config.config import DB_CONFIG

class DBClient:
    def __init__(self):
        self.connection = pymysql.connect(**DB_CONFIG)
        self.cursor = self.connection.cursor(pymysql.cursors.DictCursor)

    def query_one(self, sql: str, args=None):
        self.cursor.execute(sql, args)
        return self.cursor.fetchone()

    def close(self):
        self.cursor.close()
        self.connection.close()

# 在测试用例中使用
def test_order_submit_updates_database(login_session):
    """测试提交订单后,数据库中的订单表和订单商品表是否正确更新"""
    db = DBClient()
    # 1. 获取提交订单前的数据快照(例如,用户的订单数量)
    sql_before = "SELECT COUNT(*) as order_count FROM ecs_order_info WHERE user_id = %s"
    before_result = db.query_one(sql_before, (TEST_USER_ID,))
    order_count_before = before_result['order_count']

    # 2. 调用提交订单接口
    order_data = {...} # 构造订单数据
    resp = login_session.post_form('order.php?act=submit', data=order_data)
    assert resp.status_code == 200

    # 3. 验证数据库
    after_result = db.query_one(sql_before, (TEST_USER_ID,))
    order_count_after = after_result['order_count']
    assert order_count_after == order_count_before + 1

    # 还可以进一步查询最新订单的详细信息进行断言
    sql_new_order = "SELECT * FROM ecs_order_info WHERE user_id = %s ORDER BY order_id DESC LIMIT 1"
    new_order = db.query_one(sql_new_order, (TEST_USER_ID,))
    assert new_order['order_sn'] is not None
    assert new_order['order_amount'] == EXPECTED_AMOUNT
    db.close()

4.3 测试数据管理与参数化

硬编码的测试数据是自动化脚本的“毒药”。我们需要将数据与代码分离。

使用YAML文件管理测试数据 ( test_data/user_data.yaml )

login:
  success:
    - username: "test_user_1"
      password_md5: "e10adc3949ba59abbe56e057f20f883e" # 123456
      expected_user_id: 5
  failure:
    - username: ""
      password_md5: "e10adc3949ba59abbe56e057f20f883e"
      expected_msg: "用户名不能为空"
    - username: "not_exist"
      password_md5: "e10adc3949ba59abbe56e057f20f883e"
      expected_msg: "用户名或密码错误"

在测试用例中读取YAML数据

import yaml
import pytest

def load_test_data(file_name, key):
    with open(f'test_data/{file_name}', 'r', encoding='utf-8') as f:
        data = yaml.safe_load(f)
    return data[key]

class TestUserLogin:
    @pytest.mark.parametrize("case", load_test_data('user_data.yaml', 'login.success'))
    def test_login_success_parametrized(self, api_client, case):
        data = {'act': 'login', 'username': case['username'], 'password': case['password_md5']}
        resp = api_client.post_form('user.php', data=data)
        api_client.assert_status_code(resp, 200)
        resp_json = resp.json()
        assert resp_json['data']['user_id'] == case['expected_user_id']

5. 测试执行、报告与持续集成

5.1 测试执行与报告生成

编写好脚本后,在项目根目录下执行测试:

# 运行所有测试
pytest

# 运行特定模块
pytest test_cases/test_user.py

# 运行带有特定标记的测试
pytest -m "login"

# 生成详细的HTML报告
pytest --html=reports/report.html --self-contained-html

使用 pytest-html 插件生成的报告,会清晰展示用例通过率、失败详情、日志输出,非常适合归档和分享。

5.2 集成到CI/CD流水线(进阶)

要让测试价值最大化,就应该让它自动运行。这里给出一个最简单的GitLab CI .gitlab-ci.yml 示例:

stages:
  - test

api-test:
  stage: test
  image: python:3.9-slim
  before_script:
    - pip install -r requirements.txt
  script:
    - echo "开始执行接口自动化测试..."
    - pytest --html=report.html
  artifacts:
    when: always
    paths:
      - report.html
    expire_in: 1 week
  only:
    - merge_requests # 仅在合并请求时触发
    - main # 推送到主分支时也触发

这样,每次有代码合并请求时,都会自动运行接口测试,并将报告附加到MR中,方便代码审查者直观地看到改动是否影响了现有功能。

5.3 常见问题排查与技巧实录

在实际操作中,你一定会遇到各种问题。这里记录几个高频“坑点”:

  1. 接口返回乱码或中文乱码

    • 原因 :ECShop页面编码通常是GBK或UTF-8,而Requests默认使用ISO-8859-1解码。
    • 解决 :在封装请求客户端时,强制指定响应编码: resp.encoding = 'utf-8' resp.encoding = resp.apparent_encoding 。或者在Nginx/PHP配置中统一设置为UTF-8。
  2. Session/Cookie失效问题

    • 现象 :第一个登录接口成功,第二个需要登录态的接口失败。
    • 排查 :检查 ApiClient 是否使用了 requests.Session() 来保持会话。确保登录后返回的Cookie被正确保存在Session中,并在后续请求中自动携带。
    • 技巧 :在 request_client.py request 方法中加入日志,打印每次请求的Cookie信息。
  3. 验证码处理

    • 难题 :登录、注册等接口可能有图形验证码。
    • 策略
      • 测试环境关闭验证码 :这是最常用的方法。修改ECShop源码或数据库配置,在测试环境禁用验证码功能。
      • 万能验证码 :在代码中设置一个后端认可的“万能验证码”。
      • OCR识别(不推荐) :复杂且不稳定,仅作为最后手段。
  4. 数据库连接失败

    • 现象 pymysql 无法连接到Docker容器中的MySQL。
    • 排查
      • 确认 DB_CONFIG 中的主机是 localhost 还是容器服务名?从宿主机连接需用 localhost:3307 ,从另一个容器(如测试脚本容器)连接需用服务名 mysql:3306
      • 检查MySQL用户权限:是否允许从测试脚本所在主机/IP连接。
      • 防火墙或安全组设置。
  5. 依赖安装失败

    • 特别是 mysqlclient pymysql 在Windows上可能依赖C++编译工具。
    • 解决 :可以使用预编译的轮子,或者安装 pip install mysql-connector-python ,这是一个纯Python实现的MySQL驱动。

完成这个项目后,你得到的远不止是四个文档。你拥有了一套完整的、可复用的电商系统接口自动化测试解决方案,包括环境搭建、接口分析、用例设计、脚本开发、框架搭建和持续集成实践。这套方法论和经验,完全可以平移到其他任何Web项目的测试中。记住,测试工程师的核心价值不在于点了多少下鼠标,而在于能否通过技术手段,高效、可靠、持续地保障产品质量。这个项目,就是你迈向这个目标的一块坚实垫脚石。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐