API 负载测试
API 负载测试通常从对隔离组件的小规模负载开始。随着测试成熟,您的策略可以考虑如何更完整地测试 API。在此过程中,您将通过更多请求、更长的持续时间以及更广泛的测试范围(从隔离组件到完整的端到端工作流)来测试您的 API。
在设计 API 测试时,首先考虑 为什么 要进行 API 测试
- 您想测试哪些流程或组件?
- 您将如何运行测试?
- 哪些标准决定了可接受的性能?
回答了这些问题后,您的 API 测试策略可能会遵循以下步骤
- 编写测试脚本。编写用户流程,参数化测试数据,并对 URL 进行分组。
- 断言性能和正确性。使用检查断言系统响应,使用阈值确保系统性能符合您的 SLO。
- 建模和生成负载。选择合适的执行器来正确建模符合您测试目标的工作负载。确保负载生成器位于应有的位置。
- 迭代您的测试套件。随着时间的推移,您将能够复用脚本逻辑(例如,用户登录流程或吞吐量配置)。您还可以运行范围更广的测试,或将其作为自动化测试套件的一部分。
以下部分提供了此过程各步骤的具体解释和示例。
识别要测试的组件
开始测试之前,请确定您要测试的组件。您是想测试单个端点还是整个流程?
以下脚本使用 k6 HTTP 模块 测试单个端点。
import http from 'k6/http';
export default function () {
const payload = JSON.stringify({
name: 'lorem',
surname: 'ipsum',
});
const headers = { 'Content-Type': 'application/json' };
http.post('https://quickpizza.grafana.com/api/post', payload, { headers });
}
这是一个最小化测试,仅对一个组件进行一次调用。通常,您的测试套件将从这样的脚本发展到更复杂、更完整的工作流。在此过程中,您的测试套件将按照以下方式在测试金字塔中前进
- 测试隔离的 API。像 ab 一样反复请求 API 端点,以测试基线性能、断点或可用性。如果某个组件不满足性能要求,则它是瓶颈。通常,负载以每秒请求数设置。
- 测试集成的 API。测试一个或多个与内部或外部其他 API 交互的 API。您的重点可能在于测试一个系统或多个系统。
- 测试端到端 API 流程。模拟 API 之间真实的交互,以测试整个系统。重点通常是频繁且关键的用户场景。
您的负载测试套件应包含各种测试。但是,开始时,请从小处着手,测试单个 API 和简单的集成测试。
确定测试目的
在配置测试负载之前,您应该了解要测试 API 的流量模式。负载测试通常旨在实现以下两个目标之一
- 在预期流量下验证可靠性
- 发现异常流量下的问题和系统限制。
例如,您的团队可能会创建一套针对平均流量下频繁用户流程的测试,另一套则用于查找 API 中的断点。即使测试逻辑保持不变,其负载也可能会改变。
测试目标决定了测试类型,进而决定了测试负载。考虑以下测试类型,它们对应不同的目标负载配置文件
- 冒烟测试。验证系统在最小负载下是否运行。
- “平均”负载测试。发现系统在典型流量下如何运行。
- 压力测试。发现系统在峰值流量负载下如何运行。
- 峰值测试。发现系统在流量突然和大量增加下如何运行。
- 断点测试。逐步增加流量以发现系统断点。
- 浸泡测试。发现系统在较长时间负载下是否会或何时会发生性能下降。
您选择的测试类型会影响您规划和构建测试的方式。但每个应用、组织和测试项目都不同。我们的建议始终是
“从小处着手,频繁测试。迭代并扩展测试套件。”
确定负载配置文件后,您可以使用 k6 选项进行安排。
建模工作负载
要配置工作负载,请使用测试选项。测试负载配置测试生成的流量。k6 提供了两种主要的负载建模方式
- 通过虚拟用户 (VU),模拟并发用户
- 通过每秒请求数,模拟原始的、真实的吞吐量
注意
一般来说,您的负载测试应增加睡眠时间。睡眠时间有助于控制负载生成器,并更好地模拟人类用户的流量模式。
然而,对于 API 负载测试,关于睡眠的这些建议有一些限制。如果测试的是隔离组件,您可能只关心预定吞吐量下的性能。但是,即使在这种情况下,睡眠也可以帮助您避免负载生成器过载,并且包含一些随机毫秒的睡眠可以避免意外的并发。
当针对正常的人工运行工作流测试 API 时,像正常测试一样添加睡眠时间。
虚拟用户
当您根据 VU 建模负载时,基本负载选项是
您可以在测试脚本中定义这些选项。在以下测试中,50 个并发用户持续运行 default
流程 30 秒。
import http from 'k6/http';
export const options = {
vus: 50,
duration: '30s',
};
export default function () {
const payload = JSON.stringify({
name: 'lorem',
surname: 'ipsum',
});
const headers = { 'Content-Type': 'application/json' };
http.post('https://quickpizza.grafana.com/api/post', payload, { headers });
}
请求率
分析 API 端点性能时,负载通常以请求率报告——每秒请求数或每分钟请求数。
要根据目标请求率配置工作负载,请使用恒定到达率执行器。
constant-arrival-rate
设置一个执行脚本函数的恒定迭代率。每次迭代可以生成一个或多个请求。
要达到目标请求率(RequestsRate
),请遵循此方法
- 将频率单位设置为目标的计时单位。每秒或每分钟。
- 获取每次迭代的请求数(
RequestsPerIteration
)。 - 将迭代率设置为每秒请求率目标除以每次迭代的请求数。
rate
=RequestsRate ÷ RequestsPerIteration
。
要使用上一个示例达到 50 请求/秒的目标
- 将
timeUnit
选项设置为1s
。 - 每次迭代的请求数是 1。
- 将
rate
选项设置为 50/1(即 50)。
import http from 'k6/http';
export const options = {
scenarios: {
my_scenario1: {
executor: 'constant-arrival-rate',
duration: '30s', // total duration
preAllocatedVUs: 50, // to allocate runtime resources preAll
rate: 50, // number of constant iterations given `timeUnit`
timeUnit: '1s',
},
},
};
export default function () {
const payload = JSON.stringify({
name: 'lorem',
surname: 'ipsum',
});
const headers = { 'Content-Type': 'application/json' };
http.post('https://quickpizza.grafana.com/api/post', payload, { headers });
}
此测试在 http_reqs
指标上输出 HTTP 请求总数和 RPS
# the reported value is close to the 50 RPS target
http_reqs......................: 1501 49.84156/s
# the iteration rate is the same as rps, because each iteration runs only one request
iterations.....................: 1501 49.84156/s
有关更详细的示例,请参阅这篇关于生成恒定请求率的博文
使用 constant-arrival-rate
执行器,负载在整个测试过程中保持恒定。要逐渐增加或减少请求率,请改用ramping-arrival-rate
执行器。
有关 k6 中建模负载的所有方法,请参阅场景。
使用检查验证功能
传统上,性能测试最关注的是
- 延迟,系统响应速度
- 可用性,系统返回错误的频率。
http_req_duration
指标报告延迟,http_req_failed
报告 HTTP 请求的错误率。上一次测试运行提供了以下结果
http_req_duration..............: avg=106.14ms min=102.54ms med=104.66ms max=198.93ms p(90)=113.78ms p(95)=114.58ms
{ expected_response:true }...: avg=106.14ms min=102.54ms med=104.66ms max=198.93ms p(90)=113.78ms p(95)=114.58ms
http_req_failed................: 0.00% ✓ 0 ✗ 1501
您的测试分析可能需要超越默认指标所能提供的信息。为了进行更有意义的结果分析,您可能还需要验证功能并报告错误。
一些应用故障仅在特定负载条件下发生,例如高流量。这些错误很难发现。为了更快地找到故障原因,请对您的 API 进行检测,并验证请求是否获得预期的响应。要在 k6 中验证应用逻辑,您可以使用检查。
检查在测试执行期间验证条件。例如,您可以使用检查来验证和跟踪 API 响应。通过检查,您可以确认预期的 API 响应,例如 HTTP 状态或任何返回的数据。
我们的脚本现在验证 HTTP 响应状态、头部和负载。
import { check } from 'k6';
import http from 'k6/http';
export const options = {
scenarios: {
my_scenario1: {
executor: 'constant-arrival-rate',
duration: '30s', // total duration
preAllocatedVUs: 50, // to allocate runtime resources
rate: 50, // number of constant iterations given `timeUnit`
timeUnit: '1s',
},
},
};
export default function () {
const payload = JSON.stringify({
name: 'lorem',
surname: 'ipsum',
});
const headers = { 'Content-Type': 'application/json' };
const res = http.post('https://quickpizza.grafana.com/api/post', payload, { headers });
check(res, {
'Post status is 200': (r) => res.status === 200,
'Post Content-Type header': (r) => res.headers['Content-Type'] === 'application/json',
'Post response name': (r) => res.status === 200 && res.json().name === 'lorem',
});
}
在此片段中,所有检查都成功了
my_scenario1 ✓ [======================================] 00/50 VUs 30s 50.00 iters/s
✓ Post status is 200
✓ Post Content-Type header
✓ Post response name
当负载增加到每秒 300 个请求后,结果返回了 8811 个成功请求和 7 个失败请求
my_scenario1 ✓ [======================================] 000/300 VUs 30s 300.00 iters/s
✗ Post status is 200
↳ 99% — ✓ 8811 / ✗ 7
✗ Post Content-Type header
↳ 99% — ✓ 8811 / ✗ 7
✗ Post response name
↳ 99% — ✓ 8811 / ✗ 7
默认情况下,检查失败不会导致测试失败或中止。在这方面,检查与用于其他类型测试的断言不同。一个负载测试可以运行成千上万甚至数百万次脚本迭代,每次迭代都包含几十个断言。
一定的失败率是可接受的,这由您的 SLO 的“多少个九”或您组织的错误预算决定。
使用阈值测试您的可靠性目标
每个测试都应有一个目标。工程组织使用服务水平目标 (SLO) 来设定其可靠性目标,以验证可用性、性能或任何性能要求。
SLO 可以在不同的范围定义,例如基础设施组件级别、API 级别或整个应用级别。一些 SLO 示例包括
- 返回产品信息的 API 中,99% 的响应时间小于 600ms。
- 失败的登录请求中,99.99% 的响应时间小于 1000ms。
设计带有通过/失败标准的负载测试来验证 SLO、可靠性目标或其他重要指标。为了确保您的系统达到其 SLO,请在预生产和生产环境中频繁进行测试。
在 k6 中,您可以使用阈值来设置测试通过/失败标准。此脚本在 thresholds
对象中编码了两个 SLO,一个关于错误率(可用性),一个关于请求持续时间(延迟)。
export const options = {
thresholds: {
http_req_failed: ['rate<0.01'], // http errors should be less than 1%
http_req_duration: ['p(95)<200'], // 95% of requests should be below 200ms
},
scenarios: {
my_scenario1: {
executor: 'constant-arrival-rate',
duration: '30s', // total duration
preAllocatedVUs: 50, // to allocate runtime resources
rate: 50, // number of constant iterations given `timeUnit`
timeUnit: '1s',
},
},
};
当测试失败时,k6 CLI 会返回一个非零退出代码——这是自动化测试的必要条件。作为失败测试的示例,这是一个设置了 95% 的请求在 50ms 内完成的阈值测试的输出,http_req_duration:["p(95)<50"]
█ THRESHOLDS
http_req_duration
✗ 'p(95)<200' p(95)=348.21ms
http_req_failed
✓ 'rate<0.01' rate=0.05%
█ TOTAL RESULTS
checks_total.......................: 90 13.122179/s
checks_succeeded...................: 100.00% 90 out of 90
checks_failed......................: 0.00% 0 out of 90
✓ Post status is 200
✓ Post Content-Type header
✓ Post response name
HTTP
http_req_duration..................: avg=140.36ms min=119.08ms med=140.96ms max=154.63ms p(90)=146.88ms p(95)=148.21ms
{ expected_response:true }.......: avg=140.36ms min=119.08ms med=140.96ms max=154.63ms p(90)=146.88ms p(95)=148.21ms
http_req_failed....................: 0.00% 0 out of 45
http_reqs..........................: 45 6.56109/s
EXECUTION
iteration_duration.................: avg=152.38ms min=119.37ms med=141.27ms max=684.62ms p(90)=147.11ms p(95)=148.39ms
iterations.........................: 45 6.56109/s
vus................................: 1 min=1 max=1
vus_max............................: 1 min=1 max=1
NETWORK
data_received......................: 519 kB 76 kB/s
data_sent..........................: 4.9 kB 718 B/s
running (0m30.1s), 00/50 VUs, 1501 complete and 0 interrupted iterations
my_scenario1 ✓ [======================================] 00/50 VUs 30s 50.00 iters/s
ERRO[0030] some thresholds have failed
脚本编写注意事项
如果您以前编写过测试脚本,那么实现 k6 脚本应该会感到熟悉。k6 测试使用 JavaScript 编写,其 API 设计与其他测试框架有相似之处。
但是,与其他测试不同,负载测试会运行其脚本成百上千次,甚至数百万次。负载的存在会带来一些特定的问题。使用 k6 对 API 进行负载测试时,请考虑脚本设计的以下方面。
数据参数化
数据参数化是指用动态值替换硬编码的测试数据。参数化使得管理具有不同用户和 API 调用的负载测试更容易。参数化的常见情况是您想为每个虚拟用户或迭代使用不同的 userID
和 password
值。
例如,考虑一个包含用户信息的 JSON 文件,如下所示
{
"users": [
{ "username": "lorem", "surname": "ipsum" },
{ "username": "dolorem", "surname": "ipsum" }
]
}
您可以使用SharedArray
对象对用户进行参数化,如下所示
import { check } from 'k6';
import http from 'k6/http';
import { SharedArray } from 'k6/data';
const users = new SharedArray('users.json', function () {
return JSON.parse(open('./users.json')).users;
});
export const options = {};
export default function () {
// now, user data is not the same for all the iterations
const user = users[Math.floor(Math.random() * users.length)];
const payload = JSON.stringify({
name: user.username,
surname: user.surname,
});
const headers = { 'Content-Type': 'application/json' };
const res = http.post('https://quickpizza.grafana.com/api/post', payload, {
headers,
});
check(res, {
'Post status is 200': (r) => res.status === 200,
'Post Content-Type header': (r) => res.headers['Content-Type'] === 'application/json',
'Post response name': (r) => res.status === 200 && res.json().name === user.username,
});
}
要了解更多关于数据参数化的信息,请查看参数化示例和执行上下文变量。
错误处理和失败容忍
记住在测试逻辑中实现错误处理。在足够重的负载下,SUT 会失败并开始响应错误。虽然测试可能设计用来引发故障,但有时我们只关注最优情况,而忘记了考虑错误的重要性。
测试脚本必须处理 API 错误,以避免运行时异常,并确保它根据测试目标测试 SUT 在饱和状态下的行为。例如,我们可以扩展脚本来执行某些依赖于前一个请求结果的操作
import { check } from 'k6';
import http from 'k6/http';
import { SharedArray } from 'k6/data';
const users = new SharedArray('users.json', function () {
return JSON.parse(open('./users.json')).users;
});
export const options = {};
export default function () {
const user = users[Math.floor(Math.random() * users.length)];
const payload = JSON.stringify({
name: user.username,
surname: user.surname,
});
const headers = { 'Content-Type': 'application/json' };
const res = http.post('https://quickpizza.grafana.com/api/post', payload, {
headers,
});
check(res, {
'Post status is 200': (r) => res.status === 200,
'Post Content-Type header': (r) => res.headers['Content-Type'] === 'application/json',
'Post response name': (r) => res.status === 200 && res.json().name === user.username,
});
if (res.status === 200) {
// enters only successful responses
// otherwise, it triggers an exception
const delPayload = JSON.stringify({ name: res.json().name });
http.patch('https://quickpizza.grafana.com/api/patch', delPayload, { headers });
}
}
测试复用和模块化
负载测试范围广泛,可能涉及不同类型的测试。一般来说,团队从简单或关键的负载测试开始,并继续为新的用例、用户流程、流量模式、功能、系统等添加测试。
在此过程中,负载测试套件会随着时间增长。为了最大限度地减少重复工作,请尝试尽早复用测试脚本并将测试函数和逻辑模块化。如果您在可复用模块中编写通用场景脚本,则更容易创建不同类型的负载测试。创建新负载测试的过程如下
- 创建新的测试文件。
- 配置特定的负载和其他选项。
- 导入场景。
随着您的测试成熟,考虑创建结合多个场景的测试,以模拟更多样化的流量。
同一端点的动态 URL
默认情况下,当您使用不同的 URL 访问同一个 API 端点(例如,http://example.com/posts/${id}
)时,k6 会分别报告端点结果。这可能会产生不必要的指标数量。
要对端点结果进行分组,请使用URL 分组。
负载生成器位置
规划测试时,请考虑您的负载生成器(运行测试的机器)的位置。有时,从特定位置运行测试是测试要求。其他时候,您可以根据便利性或实用性选择位置。无论哪种方式,在设置负载生成器的位置时,请记住以下几点
- 所需位置。为了比较性能或确保结果准确,一些负载测试需要测量来自特定位置的延迟。这些测试会从与其用户区域匹配的位置启动负载生成器。
- 可选位置。其他测试尝试对照性能基线进行测量——系统性能如何从特定性能状态或时间点变化。为避免延迟结果偏差,请确保负载生成器的位置在所有测试运行中保持恒定,并避免从离 SUT 过近的位置运行测试。
内部 API
端到端 API 测试尝试复制真实世界的用户流程,这些流程从外部系统访问公共 API。其他 API 是内部的,从外部无法访问。在测试 API 集成和隔离端点时,运行内部测试的需求很常见。
如果 API 位于内部或受限环境中,您可以使用 k6 通过几种不同的方式进行测试
- 使用 k6 run 命令或 Kubernetes operator 从您的私有网络运行测试。您可以选择将测试结果存储在 k6 Cloud 或其他外部服务中。
- 对于云测试
- 为云测试流量打开您的防火墙。
- 从您的Kubernetes 集群运行云测试。
辅助工具
您可能希望将 k6 与其他 API 工具结合使用。
与 API 工具集成
围绕 REST API 的工具有很多,但专注于性能测试的不多。k6 提供了一些转换器,帮助您将更广泛的 API 工具生态系统整合到您的负载测试中
Postman 到 k6 转换器:用于从 Postman 集合创建 k6 测试。
postman-to-k6 collection.json -o k6-script.js
OpenAPI k6 生成器:用于从 Open API(前称 Swagger)定义创建 k6 测试。
openapi-generator-cli generate -i my-api-spec.json -g k6
这些工具会生成一个 k6 测试,您可以像往常一样编辑和运行它
k6 run k6-script.js
根据测试类型,这些转换器可以帮助您快速创建第一个测试,或帮助新用户上手 k6。即便如此,我们仍建议您熟悉 k6 Javascript API 并自己编写测试脚本。
使用代理录制器
另一种选择是从录制的会话自动生成 k6 测试。这些脚本可以帮助您开始构建更复杂的端到端和集成测试。
的 har-to-k6 转换器 从 HAR 格式的录制会话创建 k6 测试,HAR 格式记录 HTTP 流量。
har-to-k6 archive.tar -o k6-script.js
生成的 k6 测试可以像往常一样编辑和运行
k6 run k6-script.js
要将录制的会话导出为 HAR 格式,请使用代理录制器,例如 Fiddler 代理或 GitLab HAR 录制器。
与之前的转换器一样,录制器可以帮助测试原型。同样,我们建议您学习编写测试脚本。
超越 HTTP API
由于 Web 和 REST API 的流行,本指南主要使用了以 HTTP API 为重点的术语。但 API 不局限于 HTTP 协议。
默认情况下,k6 支持测试以下协议
import grpc from 'k6/net/grpc';
import { check, sleep } from 'k6';
const client = new grpc.Client();
client.load(null, 'quickpizza.proto');
export default () => {
client.connect('grpc-quickpizza.grafana.com:443');
const data = { ingredients: ['Tomatoes', 'Cheese'], dough: 'Thin' };
const response = client.invoke('quickpizza.GRPC/RatePizza', data);
check(response, {
'status is OK': (r) => r && r.status === grpc.StatusOK,
});
client.close();
sleep(1);
};
gRPC
但现代软件并非仅基于这些协议构建。现代基础设施和应用依赖其他 API 协议来提供新功能或提升其性能、吞吐量和可靠性。
要测试这些系统的性能和容量,测试工具应能够针对其 API 生成特定协议的请求。
- 如果 k6 不支持您需要的协议,您可以使用(或创建)扩展。扩展列表很长
- Avro
- ZeroMQ
- Ethereum
- STOMP
- MLLP
- NATS