为了综合将测试部分的知识点学以致用,本文结合 12306官网、 拿个Offer 12306项目 ,集中在 功能测试(XMind提测+用例编写)、接口测试(Java+TestNG、Python+Pytest、Apifox工具)、自动化测试 等方面(性能测试涉及较多资源知识储备,笔者不具便不展开)。
😊若有错误或者读者有更便捷的思路、操作等等,敬请批评指出。
文章目录
前置知识
- 用例的撰写方法
软件测试教程,这个视频的笔记和讲解过程适合新手
- HTML基本结构,如div、内部的name、class、id属性之类。
如果是科班的,稍微复习一下就够用了
- TestNG、Pytest、Selenium等技术(相关内容内会附上参考文章)
- Apifox 测试工具
- 接口测试用了 拿个Offer 12306项目 项目,读者也可以 Git 开源代码和看公开的部分文档进行调试(基本够用,当然项目的内部的技术文章介绍挺不错的👍)
一、功能测试
1. 用户模块
1.1 注册功能
📃 用户名规则: 由6-30位字母、数字或“_”两钟类型以上组合而成,且开头必须为字母,未注册
在12306官方网中,用户名相当于人的身份证号一样,对用户来说是唯一且不可更改的。
- 如果注销后,曾用的用户名会变为可用
- 在项目中,将注销的用户名添加到,实现注销用户名后续可用的功能,因此针对这一特殊实现添加了测试用例。
1. 等价类划分法
从需求中去划分要测试的类别,6-30位字母、数字或“_”
包含了【长度】【类型】的类别,两钟类别以上组合,开头必须为字母,未注册过
为【特殊规则】,则在类别中去划分,得出有效数据和无效数据的规则以及具体测试数据。步骤如下:
1️⃣ 明确需求
由6-30位字母、数字或“_”两钟类别以上组合而成,且开头必须为字母
2️⃣划分有效等价和无效等价
有效等价 | 无效等价 |
---|---|
6-30位 | < 6、> 30位 |
字母、数字或“_” | 其他字符,如¥%& |
两类型以上组合 | 纯字母、纯数字、纯“_”等一种类型 |
开头为字母 | 开头不为字母 |
未注册 | 已注册 |
3️⃣ 提取数据编写测试用例
用例的撰写一般需要结合等价类和边界值两种方法,此处不先给出用例。
2. 边界值法
根据“开内闭外”原则,目标数据的范围为[6,30],则:
- 上点:6、30
- 内点:15(自由取)
- 离点:5、7(舍去)、29(舍去)、31
使用 XMind 提取测试点:
- 图中的具体测试数据和黑框的概要一般不用画,此处是为了方便笔者自己理解
使用 Excel 设计用例:
- Excel部分用例的截图
复制部分 Excel 用例如下:
用例编号 | 用例标题 | 项目/模块 | 优先级 | 前置条件 | 测试步骤 | 用例数据 | 预期结果 |
---|---|---|---|---|---|---|---|
UserName-001 | 合法用户名(6位(字母、数字“_”,字母开头组成)+未注册) | User-Register | P0 | 进入注册界面 | 1.在用户名输入框输入用户名 2.点击其他任意框 | a_1234 | 合法,无错误提示 |
UserName-002 | 合法用户名(30位(字母、数字“_”,字母开头组成)+未注册) | User-Register | P0 | 进入注册界面 | 1.在用户名输入框输入用户名 2.点击其他任意框 | b_1234567890_12 34567890_123456 | 合法,无错误提示 |
UserName-003 | 合法用户名(15位(字母、数字“_”,字母开头组成)+未注册) | User-Register | P0 | 进入注册界面 | 1.在用户名输入框输入用户名 2.点击其他任意框 | c_1234567890_12 | 合法,无错误提示 |
…… |
1.2 登录功能
- 登录先检验输入的 用户名/手机号/邮箱 是否符合规定的格式规范,符合规范的账号+符合位数的密码才会进行下一步的匹配判断是否登录成功。
- 账号不存在、账号存在密码错误 两者登录的提示相同,即不会另外提示账号不存在。
使用 XMind 提取测试点:
注意:逆向用例编写时,如下图中的红框内容在熟悉流程后都可以省去,仅是为了避免粗心写错用例测试数据。
- 在创建逆向用例时,可以代入高中物理的 控制变量法,如本登录逆向用例中的“密码错误”,除了“密码”这一变量,其他变量一定是正向的,比如测试的账号一定是符合规范格式、已经注册的。
2. 购票模块
完整的XMind 提取测试点的图片1 在文末。
二、接口测试【登录】
📖 接口文档为 12306项目接口文档 。
1. 代码实现
1)Java +TestNG 实现
以【登录用例】为例,代码基本搭建参考 TestNG 单元测试框架的入门与实践 文章,注意添加依赖代码即可。
pom.xml
完整依赖代码
<dependencies>
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>6.10</version>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20210307</version>
</dependency>
</dependencies>
Java 代码
登录接口要求:
1、接口:/api/user-service/v1/login
2、Header:特定token值
3、Body (JSON格式):
{
"usernameOrMailOrPhone": "admin"
"password": "admin123456",
}
@BeforeSuite注解
:执行所有测试执行前运行一次添加测试的基础URI@DataProvider注解
:提供多组测试数据,可以模拟不同的测试场景@Test注解
:实际执行测试的方法,指定使用@DataProvider
提供的测试数据- 构建请求体:根据传入的参数构建JSON格式的请求体
- 发送请求:使用REST Assured构建POST请求,设置请求头(包括认证头部)和请求体,然后发送请求到登录端点。
import io.restassured.RestAssured;
import io.restassured.response.Response;
import org.json.JSONObject;
import org.testng.annotations.BeforeSuite;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static io.restassured.RestAssured.given;
import static io.restassured.http.ContentType.JSON;
public class TestNG_12306 {
private static final String BASE_URL = "http://localhost:8080";
private static final String LOGIN_ENDPOINT = "/api/user-service/v1/login";
private static final String AUTHORIZATION_TOKEN = "token值";
@BeforeSuite
public void setup() {
RestAssured.baseURI = BASE_URL;
}
// 使用 @DataProvider 提供测试数据
@DataProvider(name = "loginData")
public Object[][] provideLoginData() {
return new Object[][] {
// 有效的用户名和密码
{"admin", "admin123456", 200},
// 有效的用户名和空密码
{"admin", "", 200},
// 空用户名和随机密码
{"", "110110110", 200}
// 可以继续添加测试用例数据
};
}
// 测试登录接口
@Test(dataProvider = "loginData")
public void testLoginWithCredentials(String usernameOrMailOrPhone, String password, int expectedStatus) {
String jsonBody = String.format("{\"usernameOrMailOrPhone\":\"%s\", \"password\":\"%s\"}", usernameOrMailOrPhone, password);
Response response = given()
.contentType(JSON)
.header("Authorization", AUTHORIZATION_TOKEN) // 设置Authorization头部
.body(jsonBody) // 设置请求体
.when()
.post(LOGIN_ENDPOINT);
printFormattedResponse("JSON请求体:\n" ,jsonBody);
response.then()
.statusCode(expectedStatus); // 期望的状态码
printFormattedResponse("JSON响应体:\n" ,response.getBody().asString());
}
// 控制台打印JSON输出,为了便于查看自定义这个方法美化了一下
public static void printFormattedResponse(String title, String responseBody){
// 先转为JSON格式
JSONObject jsonObject = new JSONObject(responseBody);
// 使用JSONUtils格式化JSON字符串
// 第二个参数是缩进空格数
String formattedJson = jsonObject.toString(4);
// 打印美化后的JSON响应体
System.out.println(title + formattedJson);
}
}
- 控制台输出结果
JSON请求体:
{
"password": "admin123456",
"usernameOrMailOrPhone": "admin"
}
JSON响应体:
{
"code": "0",
"data": {
"realName": "徐万里",
"accessToken": "返回的token值",
"userId": "1683025552364568576",
"username": "admin"
},
"requestId": null,
"success": true,
"message": null
}
JSON请求体:
{
"password": "",
"usernameOrMailOrPhone": "admin"
}
JSON响应体:
{
"code": "B000001",
"data": null,
"requestId": null,
"success": false,
"message": "账号不存在或密码错误"
}
JSON请求体:
{
"password": "admin123456",
"usernameOrMailOrPhone": ""
}
JSON响应体:
{
"code": "B000001",
"data": null,
"requestId": null,
"success": false,
"message": "账号不存在或密码错误"
}
===============================================
Default Suite
Total tests run: 3, Failures: 0, Skips: 0
===============================================
- 美化的 TestNG 输出结果
2)Python + Pytest 实现
使用 Pytest fixture,将测试数据存储在 JSON 文件中,fixture 负责加载这些数据。
参考 Python测试框架之pytest详解 ,注意命名必须满足
test_*.py
格式或*_test.py
格式。
- 测试数据文件
test_data.json
[
{
"usernameOrMailOrPhone": "admin",
"password": "admin123456",
"expectedStatus": 200
},
{
"usernameOrMailOrPhone": "admin",
"password": "",
"expectedStatus": 200
},
{
"usernameOrMailOrPhone": "",
"password": "admin123456",
"expectedStatus": 200
}
]
test_login.py
import requests
import json
import pytest
# 基础配置
BASE_URL = "http://localhost:8080"
LOGIN_ENDPOINT = "/api/user-service/v1/login"
AUTHORIZATION_TOKEN = "token值"
# fixture用于读取测试数据文件test_data.json,module参数表示在整个模块的测试中只会被调用一次
@pytest.fixture(scope="module")
def login_data():
with open("test_data.json", "r") as file:
return json.load(file)
# 测试登录接口
# 测试函数遍历每组测试数据,构建请求头和请求体,然后发送POST请求到登录端点
def test_login_with_credentials(login_data):
for data in login_data:
headers = {
"Content-Type": "application/json",
"Authorization": AUTHORIZATION_TOKEN
}
payload = {
"usernameOrMailOrPhone": data["usernameOrMailOrPhone"],
"password": data["password"]
}
response = requests.post(
url=f"{BASE_URL}{LOGIN_ENDPOINT}", # 确保这里没有语法错误
headers=headers,
json=payload # 使用json参数自动设置请求体和Content-Type
)
# 打印请求体
print("JSON请求体:\n", json.dumps(payload, indent=4))
# 验证响应状态码
assert response.status_code == data["expectedStatus"]
# 打印响应体
print("JSON响应体:\n", json.dumps(response.json(), indent=4))
print("------------------------------------------手动分割线------------------------------------------")
# 运行测试
if __name__ == "__main__":
pytest.main("test_login.py")
- 控制输出效果
起初运行的时候,控制台不打印任何信息,后面调了配置和改成上述的代码就可以了,如果读者遇到的话,可以搜关键词
Pytest 实时标准输出和捕获标准输出
解决。
2. Apifox工具使用
接口测试有很多工具,如 Postman,而 Apifox 较为全面,涵盖大部分测试工具的功能。
虽然接口文档 12306项目接口文档 在 Apifox 并且公网可以访问,但不能导出链接至本地 Apifox 使用(笔者的在线调试有问题),所以采用 查看接口信息+在本地 Apifox 建立项目输入有关信息 后调试。
接口测试思路可以参考 Postman 接口测试。
一般来说,输入框会根据实际需求在前端进行设限,但不排除可通过抓包等方式发送给后端,所以前文所提及的功能测试中,很多情况在接口测试中依旧需要覆盖。
- Excel 编写测试用例(部分)
用例编号 | 项目模块 | 用例标题 | 请求URL | 请求类型/方法 | 前置条件 | 用例描述 | 请求参数类型 | 请求参数 | 预期结果 | 测试结果 |
---|---|---|---|---|---|---|---|---|---|---|
User001 | 用户服务 | 用户登录 | /api/user-service/v1/login | POST | 网络正常 | 用户正常登录 | json | { “usernameOrMailOrPhone”: “admin”, “password”: “admin123456” } | { “code”: “0”, “data”: { “realName”: “徐万里”, “accessToken”: “”, “userId”: “1683025552364568576”, “username”: “admin” }, “requestId”: null, “success”: true, “message”: null } | { “code”: “0”, “data”: { “realName”: “徐万里”, “accessToken”: “”, “userId”: “1683025552364568576”, “username”: “admin” }, “requestId”: null, “success”: true, “message”: null } |
User002 | 用户服务 | 用户登录 | /api/user-service/v2/login | POST | 网络正常 | 输入不存在的用户名和任意密码 | json | { “usernameOrMailOrPhone”: “abc110”, “password”: “admin123457” } | { “code”: “B000001”, “message”: “账号不存在或密码错误”, “data”: null, “requestId”: null, “success”: false } | { “code”: “B000001”, “message”: “账号不存在或密码错误”, “data”: null, “requestId”: null, “success”: false } |
User003 | 用户服务 | 用户登录 | /api/user-service/v3/login | POST | 网络正常 | 输入有效用户名和错误密码 | json | { “usernameOrMailOrPhone”: “admin”, “password”: “admin123456789” } | { “code”: “B000001”, “message”: “账号不存在或密码错误”, “data”: null, “requestId”: null, “success”: false } | { “code”: “B000001”, “message”: “账号不存在或密码错误”, “data”: null, “requestId”: null, “success”: false } |
Apifox 的接口测试和Postman一样较为简单,即编辑好接口,点击发送即可返回响应体,这里不赘述,网上很多教程。
虽然 Apifox 可以实现批量运行测试用例,但这种方式是要手动创建很多用例,较为便捷的是导入数据文件进行测试,这部分也属于自动化测试,放到下文展开。
三、自动化测试【购票 | 注册 | 登录】
📖 使用 Java
、Selenium
技术栈,具体搭建过程可参考 Selenium+Java+Chrome+IDEA 自动化测试 文章(搭建完才可以进行具体测试)。
- 本次测试网址为官网 中国铁路12306
1. 元素定位常见语法介绍
// 方式1
chromeDriver.findElement(By.id("绑定此id的元素的id")).sendKeys("填写值");
// 方式二
chromeDriver.findElementById("绑定此id的元素的id").sendKeys("填写值");
// 点击该元素
chromeDriver.findElement(By.id("绑定此id的元素的id")).click();
// 点击通过该地址找到的元素
chromeDriver.findElement(By.xpath("可定位到元素的地址")).click();
上面为较常用的类型,同理可以举一反三,除了id,还可以通过 class、name 等等元素实现找 到HTML 元素,在 IDEA 中通过代码提示输入。
2. XPATH 简要说明
可以参考 元素定位之XPath定位-学习 文章,里面较为全面介绍关于 XPATH 的语法。
元素X/
表示X
的下一层级(即从当前层往下走一层),后面可以接下一层的任意元素元素X/*
表示下一层级的所有元素元素X/div[@class='cal-cm']
表示在X
的下一层级中,符合条件class = 'cal-cm'
的div元素X/*[1]
表示X
的下一层级中,不限类型的第一个子元素元素X/..
表示X
的上一层级,相当于父元素元素X//a
双杠(此时双杠视为整体)表示寻找X
下所有符合条件的a(所有子孙)
3. 操作步骤
1️⃣ F12
进入代码调试,查看目标测试输入框的元素,如 class
id
属性
2️⃣ 若是通过 XPath 方式寻找元素,则先编写元素地址
3️⃣ Ctrl+F
调出搜索接口,输入自己编写的 XPath 地址测试是否能定位到目标元素
🎈 以购买单程为例://li[@id='J-chepiao']//a[text()='单程']
当然也有如下更直观便捷的方法:
//*[@id="megamenu-3"]/div[1]/ul/li[1]/a
- 个人认为小白可以尝试自己编写,只有自己写一遍才知道自以为的“理想”和“正确”答案的差距
4. Java+Selenium 完整代码
如果想直观看到每一行代码执行过程,可以选择:
- 每行操作语句后加入
Thread.sleep(3000);
代码- 在方法名添加调试点,自己点击一行一行调试
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.Select;
public class SeleniumTest_12306 {
static ChromeDriver chromeDriver;
public static void main (String[] args) throws InterruptedException {
openChrome();
// register(chromeDriver);
login(chromeDriver);
buyTicket(chromeDriver);
}
/**
* 业务:购买明天从广州南去深圳北的高铁,不限座位类型,学生票,乘车人为登录人
* @param chromeDriver
*/
public static void buyTicket(ChromeDriver chromeDriver){
// 单程的车票购买
// HTML结构:车票所在的li(包含着<a>、<div>) -> <div>内文字为'单程'的<a>
chromeDriver.findElement(By.xpath("//li[@id='J-chepiao']//a[text()='单程']")).click();
// 出发站
chromeDriver.findElement(By.id("J-fromStationText")).sendKeys("广州南");
// 目的站
chromeDriver.findElementById("toStationText").sendKeys("深圳北");
// HTML的日期选择结构:本月class='cal'和下一个月class='cal cal-right'两个视图,如本月30天,则有30个<div>,30个里面嵌套的<div>才是我们要的
// 如果今天在本月月末(即'今天'的下一个元素不存在),则第二天需去cal-right的div内找;若不是,则直接访问'今天'的下一个元素
WebElement nextDate = chromeDriver.findElementByXPath("//*[@class='cal']//div[text()='今天']/../following-sibling::*[1]");
if (nextDate == null){
// 即'今天'属于本月最后一天,要去下个月找
chromeDriver.findElement(By.xpath("//*[@class='cal cal-right']/div[@class='cal-cm']/*[1]")).click();
} else {
nextDate.click();
}
// 选择任意有票的车次->预订
// 在官网中,有票的单元格的class = 'yes',找到符合条件的单元格td -> 返回父级tr -> 寻找最后一个'td'(包含预订按钮)
chromeDriver.findElement(By.xpath("//td[contains(@class,'yes')]/../td[last()]/a")).click();
// 选择受让人(登录人)
chromeDriver.findElement(By.id("djPassenger_0")).click();
// 登录人账号内通过验证的乘车人
// chromeDriver.findElement(By.id("normalPassenger_2")).click();
// 票类型,即几等座
Select selectTicket = new Select(chromeDriver.findElement(By.id("ticketType_1")));
// value = 3为学生
selectTicket.selectByValue("3");
// 座位类型
Select selectSeat = new Select(chromeDriver.findElement(By.id("seatType_1")));
// 这里的座位与对应的value值并不固定(有时是1、2、3、m等等,对应座位的类别也很多)
selectSeat.selectByValue("1");
// 提交订单submitOrder_id
chromeDriver.findElement(By.id("提交订单submitOrder_id")).click();
}
public static void login(ChromeDriver chromeDriver){
chromeDriver.findElement(By.id("J-userName")).sendKeys("手机号码");
chromeDriver.findElement(By.id("J-password")).sendKeys("密码");
chromeDriver.findElement(By.id("J-login")).click();
chromeDriver.findElement(By.id("id_card")).sendKeys("身份证末四位");
chromeDriver.findElement(By.id("verification_code")).click();
chromeDriver.findElement(By.id("code")).sendKeys("手机收到的验证码");
chromeDriver.findElement(By.id("sureClick")).click();
}
public static void register(ChromeDriver chromeDriver){
chromeDriver.findElement(By.className("txt-primary")).click();
chromeDriver.findElement(By.id("userName")).sendKeys("a123456");
chromeDriver.findElement(By.id("passWord")).sendKeys("abc123");
chromeDriver.findElement(By.id("confirmPassWord")).sendKeys("abc123");
chromeDriver.findElement(By.id("regist_name")).sendKeys("姓名");
chromeDriver.findElement(By.id("cardCode")).sendKeys("18位身份证号");
Select select = new Select(chromeDriver.findElement(By.id("passengerType")));
// 乘客类型,3表示学生
select.selectByValue("3");
chromeDriver.findElement(By.id("mobileNo")).sendKeys("手机号码");
chromeDriver.findElement(By.id("checkAgree")).click();
chromeDriver.findElement(By.id("nextBtn")).click();
}
public static void openChrome(){
System.setProperty("webdriver.chrome.driver","自己电脑的谷歌驱动地址");
ChromeOptions options = new ChromeOptions();
// 表示允许所有请求
options.addArguments("--remote-allow-origins=*");
chromeDriver = new ChromeDriver(options);
chromeDriver.get("https://kyfw.12306.cn/otn/resources/login.html");
}
}
5. Apifox 的自动化测试
前置工作:在Apifox 建立项目、创建接口(这里不加以演示)
具体过程可参考 Apifox批量导入测试数据 文章。
- 在
自动化测试
界面,创建测试项目,修改请求体的传参部分代码
- 导入JSON数据,这里笔者导入上文Python测试的JSON数据文件
- 在
功能测试
->测试数据
选择刚刚上次命名的数据 -> 点击运行,结果如下,点开每个用例可以看到请求体和响应体内容。
附:通篇过程更侧重于将所学知识点、工具进行运用,虽然选择12306有关资源网站进行测试,但在企业正式测试的角度来说很多地方肯定远远不足,但学无止境,慢慢进步🔥
完整XMind测试点
↩︎