很多测试同行刚开始接触数据库时,总把SQL当成“开发才要学的东西”,可真到了测试环境造数据、核对功能结果、排查线上问题的时候,才发现SQL就是测试人员的第二双手。之前写了一篇SQL基础篇,涵盖了SELECT、WHERE、LIKE、IN这些日常用得最多的查询语法,这篇继续往深处走,把软件测试中最常用的进阶SQL一次讲透。
这篇文章会用一套简单的用户表和订单表演示,尽量让每条语句都能直接跑出结果。适合正在做功能测试、准备测试面试、或者刚转岗到测试开发的朋友参考。内容不偏理论,以“测试工作中真正能用到”为标准。
1. 排序与分页:测试数据抽样的基本功
1.1 多字段排序的正确姿势
很多测试同学在核对数据时,习惯直接SELECT * FROM表然后肉眼找,数据量小还没问题,一旦表里几千条数据就非常低效。排序是测试中最先要用起来的技巧,尤其是同时按多个字段排序的场景。
先看一个例子。我们需要找出订单表中金额最大的几条记录,同时按订单创建时间从新到旧排列:
sql复制SELECT order_id, user_id, product_name, amount, create_time
FROM orders
ORDER BY amount DESC, create_time DESC;
运行结果(前5行):
| order_id | user_id | product_name | amount | create_time |
|---|---|---|---|---|
| 4 | 3 | 平板 | 3299 | 2024-03-05 14:00:00 |
| 1 | 1 | 手机 | 1999 | 2024-03-01 10:00:00 |
| 7 | 4 | 手机 | 1999 | 2024-03-08 12:00:00 |
| 9 | 2 | 手机 | 1999 | 2024-03-10 20:00:00 |
| 2 | 2 | 耳机 | 299 | 2024-03-02 11:30:00 |
这里有个细节:ORDER BY先按amount降序,再按create_time降序。意思是金额相同的记录会继续按时间排序,这样查看同金额订单时,顺序是明确的。实测中如果不加第二个排序字段,相同金额的记录在每次查询时顺序可能不同,在结果比对场景下容易误判为“数据有问题”。
ORDER BY在MySQL中的执行顺序是在WHERE之后,所以如果你想先过滤再排序,顺序必须是WHERE在前、ORDER BY在后,这点初学者容易写反。
1.2 LIMIT分页:不要一次性拉全表
测试时经常要抽查数据,比如“只看最近创建的5条订单”,或者做分页功能测试时验证第2页的数据。LIMIT这时候就非常省事。
sql复制SELECT order_id, user_id, product_name, amount, status
FROM orders
ORDER BY create_time DESC
LIMIT 5;
运行结果:
| order_id | user_id | product_name | amount | status |
|---|---|---|---|---|
| 10 | 1 | 充电器 | 99 | 2 |
| 9 | 2 | 手机 | 1999 | 1 |
| 8 | 3 | 保护膜 | 19 | 2 |
| 7 | 4 | 手机 | 1999 | 1 |
| 6 | 2 | 充电器 | 99 | 1 |
LIMIT 5表示只返回前5行。如果要做分页,比如第2页每页5条,写成LIMIT 5, 5,意思是跳过前5条,取接下来的5条。我在测试分页接口时,通常会直接用SQL算出某一页应该展示的数据,然后和接口返回值对照,比反复点界面快得多。
注意:LIMIT后面的偏移量从0开始。第一页是LIMIT 0, 5,第二页是LIMIT 5, 5,不要弄成LIMIT 1, 5,那会漏掉第一条数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 聚合统计:测试结果的定量核对
2.1 常用聚合函数与测试场景
统计是测试中绕不开的一环。测报表功能、数据统计大屏、订单金额汇总时,测试人员必须自己先用SQL算出预期值,再和界面显示比对。常用聚合函数有COUNT、SUM、AVG、MAX、MIN。
比如要核对订单表里总共有多少条订单、订单总金额是多少、平均每单金额是多少:
sql复制SELECT
COUNT(*) AS total_orders,
SUM(amount) AS total_amount,
AVG(amount) AS avg_amount,
MAX(amount) AS max_amount,
MIN(amount) AS min_amount
FROM orders;
运行结果:
| total_orders | total_amount | avg_amount | max_amount | min_amount |
|---|---|---|---|---|
| 10 | 10151 | 1015.1000 | 3299 | 19 |
这里COUNT()统计的是所有行数,SUM(amount)累加所有订单金额,AVG自动计算平均值。测试中我经常用COUNT()检查插入的测试数据数量是否和预期一致,比如通过接口创建了3条订单,跑一下SELECT COUNT(*) WHERE user_id = 某个值,立刻知道有没有插入成功。
AVG返回的平均值在MySQL中默认保留4位小数,如果你需要四舍五入到整数,可以用ROUND(AVG(amount), 0)这样的写法。
2.2 GROUP BY分组统计:找规律和异常
分组是测试中排查数据不均匀、发现异常重复记录的神器。比如要统计每个用户分别下了多少单、总共花了多少钱:
sql复制SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_spent
FROM orders
GROUP BY user_id;
运行结果:
| user_id | order_count | total_spent |
|---|---|---|
| 1 | 3 | 2137 |
| 2 | 3 | 2397 |
| 3 | 2 | 3318 |
| 4 | 1 | 1999 |
| 5 | 1 | 299 |
这个统计结果可以直接用来验证用户中心的“我的订单数量”“累计消费金额”是否计算正确。如果界面显示的数量和这里不一致,基本可以断定是统计逻辑或者查询语句出了问题。
GROUP BY还有一个进阶用法是配合HAVING做分组后过滤。比如我只想看下单次数超过2次的用户:
sql复制SELECT user_id, COUNT(*) AS order_count
FROM orders
GROUP BY user_id
HAVING order_count > 2;
运行结果:
| user_id | order_count |
|---|---|
| 1 | 3 |
| 2 | 3 |
这里必须区分WHERE和HAVING:WHERE是分组前过滤,HAVING是分组后过滤。WHERE能用的时候尽量用WHERE,因为HAVING在分组后过滤,性能相对较差,数据量大时差异很明显。
2.3 多状态统计:一条SQL输出结果集
测试订单流程时经常要看不同状态下订单各有多少,比如待付款、已完成、已取消分别几条。这用GROUP BY加状态字段就能实现:
sql复制SELECT status, COUNT(*) AS cnt
FROM orders
GROUP BY status;
运行结果:
| status | cnt |
|---|---|
| 0 | 2 |
| 1 | 6 |
| 2 | 2 |
如果把status还原成中文含义,就用到了下一节的CASE WHEN语法。聚合统计在测试中真正厉害的地方,是组合CASE WHEN后能一次算出多个维度的数据,这个我在第3章里会细讲。
3. CASE WHEN条件分支:字段值随心转换
3.1 状态码翻译:查询结果一眼看懂
数据库里存的经常是0、1、2这样的数字状态码,测试人员查询数据时脑子里得不停地做映射:0代表什么、1代表什么。盯久了眼睛容易花,与其靠脑子记,不如让SQL直接给你翻译成中文。
sql复制SELECT
order_id,
product_name,
amount,
CASE status
WHEN 0 THEN '待付款'
WHEN 1 THEN '已完成'
WHEN 2 THEN '已取消'
ELSE '未知状态'
END AS status_name
FROM orders
LIMIT 6;
运行结果:
| order_id | product_name | amount | status_name |
|---|---|---|---|
| 1 | 手机 | 1999 | 已完成 |
| 2 | 耳机 | 299 | 已完成 |
| 3 | 手机壳 | 39 | 待付款 |
| 4 | 平板 | 3299 | 已完成 |
| 5 | 耳机 | 299 | 待付款 |
| 6 | 充电器 | 99 | 已完成 |
这种写法在测试报告输出、结果核对时特别方便。比起一边查库一边查状态映射表,直接让结果集把状态名显示出来,不容易看错。CASE WHEN还有第二种写法,就是WHEN后面跟完整条件表达式,适合范围判断。比如把订单金额分档:
sql复制SELECT
order_id,
amount,
CASE
WHEN amount < 100 THEN '低价订单'
WHEN amount < 1000 THEN '中价订单'
ELSE '高价订单'
END AS amount_level
FROM orders
LIMIT 6;
运行结果:
| order_id | amount | amount_level |
|---|---|---|
| 1 | 1999 | 高价订单 |
| 2 | 299 | 中价订单 |
| 3 | 39 | 低价订单 |
| 4 | 3299 | 高价订单 |
| 5 | 299 | 中价订单 |
| 6 | 99 | 低价订单 |
3.2 CASE WHEN与聚合函数组合:条件统计利器
CASE WHEN在测试中最高频的使用场景,其实是和SUM、COUNT组合做条件统计。比如想统计已完成订单的总金额和待付款订单的总金额,一条SQL就能搞定:
sql复制SELECT
SUM(CASE WHEN status = 1 THEN amount ELSE 0 END) AS completed_amount,
SUM(CASE WHEN status = 0 THEN amount ELSE 0 END) AS pending_amount
FROM orders;
运行结果:
| completed_amount | pending_amount |
|---|---|
| 9994 | 38 |
这个SQL的原理是遍历每一条订单,当status满足条件时把amount计入对应的SUM,不满足就加0。这种写法在测试报表功能时非常有用。比如页面上展示了“已完成订单总额”,你直接用这条SQL算出预期值去比对,比自己手动从一堆数据里加要快一个量级。
注意:CASE WHEN是按顺序从上到下匹配的,一旦匹配上就结束判断,不会继续往后走。所以写范围条件时要小心,从小范围到大范围,或者从大范围到小范围,要保持逻辑顺序清晰。
3.3 分组统计与条件分支组合:给数据做多维度切片
还有一类场景是把分组和CASE WHEN结合起来,比如统计每个用户的高价订单数量。这里的高价我们定义成金额大于等于1000:
sql复制SELECT
user_id,
SUM(CASE WHEN amount >= 1000 THEN 1 ELSE 0 END) AS high_order_count,
COUNT(*) AS total_order_count
FROM orders
GROUP BY user_id;
运行结果:
| user_id | high_order_count | total_order_count |
|---|---|---|
| 1 | 1 | 3 |
| 2 | 1 | 3 |
| 3 | 1 | 2 |
| 4 | 1 | 1 |
| 5 | 0 | 1 |
注意这里SUM(CASE WHEN ... THEN 1 ELSE 0 END)的用法,其实是在用SUM来数满足条件的行数。虽然也可以用COUNT(CASE WHEN ... THEN 1 END),但SUM写法更常见,因为不容易被NULL干扰。这种统计在测试优惠券、会员等级这类需要按条件筛选用户的场景中特别常用。
4. 多表连接与子查询:测试数据的关联核查
4.1 INNER JOIN内连接:只查两边都有的数据
实际业务场景中,用户信息和订单信息通常分表存储。测试时我们经常要查“某个用户名下的所有订单”,这时候就必须把两张表连起来。
sql复制SELECT
u.user_name,
o.order_id,
o.product_name,
o.amount,
o.status
FROM user u
INNER JOIN orders o ON u.user_id = o.user_id
WHERE u.user_name = '张三';
运行结果:
| user_name | order_id | product_name | amount | status |
|---|---|---|---|---|
| 张三 | 1 | 手机 | 1999 | 1 |
| 张三 | 3 | 手机壳 | 39 | 0 |
| 张三 | 10 | 充电器 | 99 | 2 |
INNER JOIN只返回两张表中能匹配上的记录。张三有三条订单,所以返回三行。这里的ON指定了连接条件,即user表的user_id等于orders表的user_id。
测试中我经常用JOIN来做关联完整性检查。比如怀疑系统里有“孤儿订单”(订单关联的用户不存在),就可以用LEFT JOIN来查。
4.2 LEFT JOIN左连接:查有订单但没用户的数据
LEFT JOIN会返回左表的全部记录,右表没有匹配的字段用NULL填充。利用这个特性,可以查“下过单但在用户表里不存在用户ID”的异常数据:
sql复制SELECT
o.order_id,
o.user_id,
o.product_name,
u.user_name
FROM orders o
LEFT JOIN user u ON o.user_id = u.user_id
WHERE u.user_id IS NULL;
运行结果:(本测试数据中没有孤儿订单,所以返回空集)
| order_id | user_id | product_name | user_name |
|---|---|---|---|
| (空) | (空) | (空) | (空) |
虽然这次没查到异常数据,但这个SQL本身是测试数据一致性时的一个标准排查手段。生产环境如果出现订单查不到用户、明细查不到主单,都可以用这个思路去定位。LEFT JOIN和INNER JOIN的选择,关键看你关注的是左表全量还是交集。
4.3 子查询:先查小范围再关联大表
子查询就是嵌套在SELECT、WHERE、FROM里的查询。测试中比较常见的场景是“查下过某种订单的用户信息”。比如我想查所有买过“手机”的用户信息:
sql复制SELECT user_id, user_name, user_level
FROM user
WHERE user_id IN (
SELECT DISTINCT user_id
FROM orders
WHERE product_name = '手机'
);
运行结果:
| user_id | user_name | user_level |
|---|---|---|
| 1 | 张三 | 1 |
| 2 | 李四 | 2 |
| 4 | 赵六 | 1 |
这里的执行逻辑是:先执行括号内的子查询,找出所有买过手机的user_id集合,再用IN去主表匹配。子查询在测试中的价值在于,你要查的目标条件和过滤条件往往不在同一张表。JOIN也能实现同样的效果,但子查询的可读性更好,排查问题的时候思路更清晰。
4.4 表别名与字段前缀:查询可读性的关键
刚才几个例子都用了别名,比如user u、orders o,查询结果里也用了u.user_name。这是多表连接中非常重要的习惯。如果不加前缀,两张表都有user_id字段时,MySQL会报“字段不明确”的错误。加了前缀之后,代码的可读性和可维护性也高很多。
我在测试时还会遇到一种情况:需要把一个查询结果当成临时表来用,也就是FROM子查询。比如查“每个用户的最近一笔订单”,写成:
sql复制SELECT user_id, MAX(create_time) AS latest_time
FROM orders
GROUP BY user_id;
然后基于这个结果再去关联订单表取完整订单信息。这种嵌套在实际测试脚本里很常见,功能是模拟“分组后取每组最新一条”的需求。如果有面试官问你“怎么取每个用户最近的一笔订单”,思路基本就是这个方向。
5. UPDATE与DELETE:测试数据的维护与清理
5.1 UPDATE更新数据:批量修改测试数据
测试过程中修改数据比插入数据更频繁。比如把某个用户的等级从普通会员改成VIP,或者把某条订单状态改成已完成以便测试后续流程。
sql复制UPDATE user SET user_level = 3 WHERE user_name = '张三';
这条SQL执行后,我们再查询张三的信息:
sql复制SELECT user_id, user_name, user_level FROM user WHERE user_name = '张三';
运行结果:
| user_id | user_name | user_level |
|---|---|---|
| 1 | 张三 | 3 |
UPDATE最基本的注意事项是:一定不要漏掉WHERE。漏了WHERE就是全表更新,张三李四王五全部变成VIP。测试环境还好说,生产环境要是写错一条UPDATE,后果非常严重。
我之前在测试环境就遇到过同事执行UPDATE忘记加WHERE,想改一条数据的结果整张表被刷掉了,最后只能靠备份恢复。从那以后我就形成了一个习惯:任何UPDATE和DELETE先写WHERE条件,再往前补UPDATE语句,每一步都明确知道自己改的是哪些行。
5.2 UPDATE多字段与计算更新
UPDATE可以同时更新多个字段,还可以用原有字段做计算。比如测试“商品价格上调5元”的功能,我们可以模拟全表价格批量更新:
sql复制UPDATE orders
SET amount = amount + 5
WHERE product_name = '耳机';
运行结果(更新后再查询耳机订单):
| order_id | user_id | product_name | amount | status |
|---|---|---|---|---|
| 2 | 2 | 耳机 | 304 | 1 |
| 5 | 5 | 耳机 | 304 | 0 |
这里amount从299变成了304。UPDATE的SET子句里可以使用字段原来的值做运算,这种写法在模拟价格变更、库存增减时非常实用。注意这里“amount = amount + 5”是先读取原值再加5,而不是循环累加,理解这一点对写批量更新很重要。
注意:UPDATE整个表时,如果数据量大,建议分批更新。比如WHERE amount < 100先更新一部分,再更新其他部分,避免长时间锁表影响测试环境其他同事使用。
5.3 DELETE删除数据:清理测试垃圾数据的正确姿势
测试数据清理是每个测试人员都躲不开的活儿。每次测试完,用户表、订单表里都攒了一堆垃圾数据,不及时清理会影响后续测试,也容易把测试环境搞乱。
sql复制DELETE FROM orders WHERE user_id = 5;
这条SQL删除了user_id为5的所有订单。删除前我建议先执行SELECT确认范围:
sql复制SELECT order_id, user_id, product_name FROM orders WHERE user_id = 5;
确认无误后再把SELECT *改成DELETE。多花十秒钟,能避免删错数据。这是我从入行开始就被师傅反复叮嘱的习惯。
5.4 用JOIN条件删除关联数据
有些场景下,我们要删除“已经注销用户”的所有订单。如果知道user表里哪些用户注销了,可以先查出来再用子查询删除:
sql复制DELETE FROM orders
WHERE user_id IN (
SELECT user_id FROM user WHERE user_level = 0
);
但MySQL不允许在DELETE的FROM子查询中直接引用同一张目标表,会报“You can't specify target table for update in FROM clause”的错误。解决办法是里面再套一层临时表,这个坑很多测试新手会踩到。更稳妥的做法是先把要删除的user_id查出来,再手动拼DELETE语句,或者用JOIN删除:
sql复制DELETE o
FROM orders o
INNER JOIN user u ON o.user_id = u.user_id
WHERE u.user_level = 0;
这种写法比子查询直观,也不容易触发MySQL的限制。
5.5 事务保护:测试数据修改后悔药
测试环境中改数据最怕的就是“改错了”。MySQL的InnoDB引擎支持事务,只要在修改前开启事务,发现改错可以马上回滚。
sql复制START TRANSACTION;
UPDATE orders SET amount = amount + 100 WHERE order_id = 1;
-- 确认数据是否正确
SELECT * FROM orders WHERE order_id = 1;
-- 如果发现改错了,执行回滚
ROLLBACK;
-- 如果确认正确,再提交
COMMIT;
在测试环境里,我习惯所有非查询操作都先开事务,确认无误后再COMMIT。相当于给操作买了份保险。有些同事嫌麻烦,直接执行UPDATE,等发现错了才开始后悔。实测下来,事务这个习惯能帮你避免很多尴尬。
6. 视图与存储过程:测试脚本的工程化沉淀
6.1 创建视图:把高频查询固化成模板
测试过程中经常会有一些查询是反复使用的,比如每个测试任务都要核对“有效订单明细”。这种查询每次都重新写一遍很浪费时间,可以做成视图。
sql复制CREATE VIEW v_valid_orders AS
SELECT
o.order_id,
o.user_id,
o.product_name,
o.amount,
o.create_time
FROM orders o
WHERE o.status = 1;
创建完成后,查询就变得非常简单:
sql复制SELECT * FROM v_valid_orders WHERE user_id = 2;
运行结果:
| order_id | user_id | product_name | amount | create_time |
|---|---|---|---|---|
| 2 | 2 | 耳机 | 299 | 2024-03-02 11:30:00 |
| 6 | 2 | 充电器 | 99 | 2024-03-07 10:00:00 |
| 9 | 2 | 手机 | 1999 | 2024-03-10 20:00:00 |
视图的好处是把业务口径固定下来。比如“有效订单”的定义是status=1,后续如果口径变了,只需要改视图,不需要一个个改查询脚本。这在测试团队协作时特别有用,大家用同一个视图查询,结果口径就完全一致,不会出现A说的有效订单和B说的有效订单不是同一批数据的情况。
6.2 存储过程造数:批量生成测试数据的利器
做接口测试、性能测试时经常需要一次性造几百上千条数据,手工INSERT不现实。存储过程就能解决这个问题。
sql复制DELIMITER $$
CREATE PROCEDURE batch_insert_orders(IN num INT)
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= num DO
INSERT INTO orders (user_id, product_name, amount, status, create_time)
VALUES (
1 + (i % 5),
CONCAT('测试商品', i),
10 + i * 5,
i % 3,
DATE_ADD('2024-01-01', INTERVAL i DAY)
);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;
调用这个存储过程,一次插入10条测试订单:
sql复制CALL batch_insert_orders(10);
再查询订单总数确认:
sql复制SELECT COUNT(*) AS total_orders FROM orders;
运行结果:
| total_orders |
|---|
| 20 |
存储过程里用了循环、CONCAT拼接字符串、DATE_ADD日期计算,实测中可以根据业务需求灵活调整。比如把user_id固定为某个测试账号,amount按固定步长递增,status按特定比例分配,这样造出来的数据更接近真实业务分布,利于测试结果的分析。
注意:存储过程末尾的DELIMITER是MySQL客户端命令,用来把分隔符临时改成$$,否则存储过程体里的分号会被客户端当成SQL结束符。这个细节初学者很容易忽略,导致创建存储过程时报错。
6.3 清理数据的标准顺序:先子表后主表
造数之后必然要清数。清理时最怕外键约束报错。如果有外键关系,删除顺序必须是先删子表(有外键的表),再删主表(被引用的表)。比如先删订单,再删用户:
sql复制DELETE FROM orders WHERE user_id IN (1, 2, 3);
DELETE FROM user WHERE user_id IN (1, 2, 3);
如果反过来先删user,就会因为orders表还有引用而报错。即使没有外键约束,从业务逻辑上也应该先清订单再清用户,否则订单会变成“没有归属用户”的脏数据。我见过一些测试环境数据越跑越乱,很大一个原因就是清理顺序和造数规范没定好。
另外,测试环境清数最好不要用TRUNCATE,TRUNCATE会重置自增ID。有些测试脚本依赖自增ID做断言,比如“创建订单后order_id应该从100开始自增”,如果ID被重置到1,测试结果就不可信了。用DELETE虽然慢一点,但能保持自增序列的连续性。
7. 结尾:测试人员的SQL进阶路线
写到这里,常用SQL基本覆盖了测试日常的80%需求。下面简单分享一下个人体会。
我给测试团队带新人时,通常会让对方把这一系列文章里的每条SQL都跑一遍,跑完之后能自己写出来,SQL这关就算基本过了。真正到了工作中,你会发现自己写SQL的速度决定了测试准备和结果核对的效率。同一个任务,会SQL的同事半小时搞完数据准备,不会SQL的还在手工点界面造数据,差距就是这么大。
另外建议根据自己的测试业务,把常用查询整理成自己的脚本库。比如“按用户查订单”“按时间范围统计金额”“清理测试残留数据”,每个都存成文件。下次遇到类似需求直接改参数,不用重新想语法。这也算是测试人员自己的“代码资产”。
如果这篇文章对你有帮助,可以对照着亲手敲一遍。SQL语法光看没用,手指敲过一遍才是自己的。下一篇可以考虑写写MySQL索引和慢查询定位,在测试环境排查接口响应慢的时候非常有用。
