HarmonyOS开发:API测试——接口测试
HarmonyOS开发:API测试——接口测试
核心要点:HTTP接口是前后端协作的契约,接口测试就是验证这个契约有没有被遵守。请求构造、响应验证、契约测试、Mock Server——四板斧下去,接口问题无处藏身。
一、背景与动机
你有没有遇到过这种场景?前端调接口,返回500,后端说"我这边没问题啊";联调了半天,发现是字段名从userName改成了username,但没人通知前端。
这种事在项目里太常见了。接口是前后端、模块与模块之间的桥梁,桥不稳,整栋楼都晃。手动用Postman点一遍?接口少的时候还行,接口一多,你根本点不过来。更要命的是,改了一个接口,谁知道影响了哪些调用方?
API测试解决的就是这个问题:用代码验证接口行为是否符合契约。请求参数对不对?返回格式对不对?状态码对不对?异常情况处理对不对?这些全都可以自动化验证。而且接口测试跑起来快,不依赖UI,是性价比最高的测试类型之一。
二、核心原理
2.1 API测试分层体系
flowchart TB
A[API测试体系] --> B[请求构造层]
A --> C[响应验证层]
A --> D[契约测试层]
A --> E[Mock服务层]
B --> B1[URL与路径参数]
B --> B2[请求头构造]
B --> B3[请求体序列化]
B --> B4[认证Token注入]
C --> C1[状态码验证]
C --> C2[响应体结构验证]
C --> C3[业务数据验证]
C --> C4[响应时间验证]
D --> D1[接口契约定义]
D --> D2[契约一致性校验]
D --> D3[契约变更检测]
E --> E1[本地Mock Server]
E --> E2[场景化响应]
E --> E3[延迟与故障模拟]
classDef mainStyle fill:#4CAF50,stroke:#388E3C,color:#fff,font-weight:bold
classDef reqStyle fill:#3498DB,stroke:#2980B9,color:#fff
classDef resStyle fill:#2ECC71,stroke:#27AE60,color:#fff
classDef contractStyle fill:#F39C12,stroke:#E67E22,color:#fff
classDef mockStyle fill:#9B59B6,stroke:#8E44AD,color:#fff
class A mainStyle
class B,B1,B2,B3,B4 reqStyle
class C,C1,C2,C3,C4 resStyle
class D,D1,D2,D3 contractStyle
class E,E1,E2,E3 mockStyle
2.2 HarmonyOS HTTP请求机制
HarmonyOS通过@ohos.net.http模块发起HTTP请求,核心API:
import http from '@ohos.net.http'
// 创建HTTP请求
const httpRequest = http.createHttp()
// 发起请求
const response = await httpRequest.request('https://api.example.com/users', {
method: http.RequestMethod.GET,
header: { 'Content-Type': 'application/json' }
})
// 响应结构
// response.responseCode 状态码
// response.result 响应体(string或object)
// response.header 响应头
关键点:每次请求都要createHttp()创建新实例,用完必须destroy()释放资源。复用实例会导致请求状态混乱。
2.3 接口契约测试原理
接口契约测试的核心思想:先定义接口应该长什么样,再验证实际接口是否符合定义。
定义契约(JSON Schema) → 发起真实请求 → 比对响应与契约 → 报告差异
契约一旦定义,任何接口变更都必须先更新契约。如果接口返回了契约中没有的字段,或者字段类型变了,契约测试就会失败——这就强制保证了接口变更有迹可循。
三、代码实战
3.1 基础用法:HTTP接口请求构造与响应验证
// ApiTestHelper.ets - API测试辅助工具
import http from '@ohos.net.http'
export class ApiTestHelper {
private baseUrl: string
private token: string = ''
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
// 设置认证Token
setToken(token: string): void {
this.token = token
}
// 构造通用请求头
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`
}
return headers
}
// GET请求
async get(path: string, params?: Record<string, string>): Promise<ApiResponse> {
const httpRequest = http.createHttp()
try {
let url = `${this.baseUrl}${path}`
if (params) {
const query = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&')
url += `?${query}`
}
const response = await httpRequest.request(url, {
method: http.RequestMethod.GET,
header: this.buildHeaders()
})
return this.parseResponse(response)
} finally {
httpRequest.destroy()
}
}
// POST请求
async post(path: string, body?: object): Promise<ApiResponse> {
const httpRequest = http.createHttp()
try {
const response = await httpRequest.request(`${this.baseUrl}${path}`, {
method: http.RequestMethod.POST,
header: this.buildHeaders(),
extraData: body ? JSON.stringify(body) : undefined
})
return this.parseResponse(response)
} finally {
httpRequest.destroy()
}
}
// 解析响应
private parseResponse(response: http.HttpResponse): ApiResponse {
let data: object | null = null
if (typeof response.result === 'string') {
try {
data = JSON.parse(response.result)
} catch (e) {
data = null
}
} else if (typeof response.result === 'object') {
data = response.result as object
}
return {
statusCode: response.responseCode,
headers: response.header as Record<string, string>,
data: data,
raw: response.result
}
}
}
// 响应结构定义
export interface ApiResponse {
statusCode: number
headers: Record<string, string>
data: object | null
raw: string | object
}
基础接口测试用例:
// UserApiTest.ets - 用户接口测试
import { describe, it, beforeAll, assertEqual, assertNotNull, assertTrue } from '@ohos/hypium'
import { ApiTestHelper } from '../ApiTestHelper'
export default function userApiTest() {
describe('用户接口测试', () => {
const api = new ApiTestHelper('https://api.example.com')
beforeAll(async () => {
// 登录获取Token
const loginRes = await api.post('/auth/login', {
username: 'testuser',
password: 'testpass123'
})
assertEqual(loginRes.statusCode, 200, '登录应成功')
const loginData = loginRes.data as Record<string, Object>
api.setToken(loginData['token'] as string)
})
it('GET /users_返回用户列表', 0, async () => {
const res = await api.get('/users')
// 验证状态码
assertEqual(res.statusCode, 200, '状态码应为200')
// 验证响应体结构
assertNotNull(res.data, '响应体不应为null')
const body = res.data as Record<string, Object>
assertTrue(Array.isArray(body['items']), 'items应为数组')
// 验证数据内容
const items = body['items'] as Array<Record<string, Object>>
assertTrue(items.length > 0, '用户列表不应为空')
assertNotNull(items[0]['id'], '用户对象应包含id字段')
assertNotNull(items[0]['name'], '用户对象应包含name字段')
})
it('GET /users/:id_返回指定用户', 0, async () => {
const res = await api.get('/users/u-001')
assertEqual(res.statusCode, 200)
const user = res.data as Record<string, Object>
assertEqual(user['id'], 'u-001', '返回的用户ID应匹配')
assertNotNull(user['name'])
assertNotNull(user['email'])
})
it('GET /users/:id_用户不存在返回404', 0, async () => {
const res = await api.get('/users/non-exist-id')
assertEqual(res.statusCode, 404, '不存在的用户应返回404')
})
})
}
3.2 进阶用法:接口契约测试
契约测试的核心是定义接口的"预期形状",然后验证实际响应是否符合:
// ApiContract.ets - 接口契约定义
export interface ApiContract {
// 接口路径
path: string
// 请求方法
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
// 预期状态码
expectedStatus: number
// 响应体契约(JSON Schema简化版)
responseSchema: ResponseSchema
}
export interface ResponseSchema {
// 必须存在的字段
requiredFields: Array<string>
// 字段类型定义
fieldTypes: Record<string, 'string' | 'number' | 'boolean' | 'array' | 'object'>
// 数组元素契约(如果字段是数组)
arrayItemSchema?: ResponseSchema
}
// ContractValidator.ets - 契约验证器
export class ContractValidator {
// 验证响应是否符合契约
static validate(response: ApiResponse, contract: ApiContract): ContractResult {
const errors: Array<string> = []
// 1. 验证状态码
if (response.statusCode !== contract.expectedStatus) {
errors.push(`状态码不匹配: 预期${contract.expectedStatus}, 实际${response.statusCode}`)
}
// 2. 验证响应体
if (!response.data) {
errors.push('响应体为空')
return { passed: false, errors }
}
const data = response.data as Record<string, Object>
// 3. 验证必须字段
for (const field of contract.responseSchema.requiredFields) {
if (data[field] === undefined || data[field] === null) {
errors.push(`缺少必须字段: ${field}`)
}
}
// 4. 验证字段类型
for (const [field, expectedType] of Object.entries(contract.responseSchema.fieldTypes)) {
const value = data[field]
if (value === undefined || value === null) continue // 非必须字段跳过
const actualType = this.getType(value)
if (actualType !== expectedType) {
errors.push(`字段${field}类型不匹配: 预期${expectedType}, 实际${actualType}`)
}
}
// 5. 验证数组元素契约
if (contract.responseSchema.arrayItemSchema) {
for (const [field, type] of Object.entries(contract.responseSchema.fieldTypes)) {
if (type === 'array' && Array.isArray(data[field])) {
const items = data[field] as Array<Record<string, Object>>
items.forEach((item, index) => {
for (const reqField of contract.responseSchema.arrayItemSchema!.requiredFields) {
if (item[reqField] === undefined || item[reqField] === null) {
errors.push(`${field}[${index}]缺少必须字段: ${reqField}`)
}
}
})
}
}
}
return {
passed: errors.length === 0,
errors
}
}
// 获取值的类型
private static getType(value: Object): string {
if (Array.isArray(value)) return 'array'
return typeof value as string
}
}
export interface ContractResult {
passed: boolean
errors: Array<string>
}
使用契约测试:
// UserApiContractTest.ets - 用户接口契约测试
import { describe, it, beforeAll, assertTrue, assertEqual } from '@ohos/hypium'
import { ApiTestHelper } from '../ApiTestHelper'
import { ApiContract, ContractValidator } from '../ContractValidator'
export default function userApiContractTest() {
describe('用户接口契约测试', () => {
const api = new ApiTestHelper('https://api.example.com')
// 定义用户列表接口的契约
const userListContract: ApiContract = {
path: '/users',
method: 'GET',
expectedStatus: 200,
responseSchema: {
requiredFields: ['items', 'total', 'page'],
fieldTypes: {
items: 'array',
total: 'number',
page: 'number'
},
arrayItemSchema: {
requiredFields: ['id', 'name', 'email'],
fieldTypes: {
id: 'string',
name: 'string',
email: 'string'
}
}
}
}
// 定义用户详情接口的契约
const userDetailContract: ApiContract = {
path: '/users/:id',
method: 'GET',
expectedStatus: 200,
responseSchema: {
requiredFields: ['id', 'name', 'email', 'createdAt'],
fieldTypes: {
id: 'string',
name: 'string',
email: 'string',
createdAt: 'string',
avatar: 'string',
bio: 'string'
}
}
}
beforeAll(async () => {
const loginRes = await api.post('/auth/login', {
username: 'testuser',
password: 'testpass123'
})
const loginData = loginRes.data as Record<string, Object>
api.setToken(loginData['token'] as string)
})
it('GET /users_符合契约定义', 0, async () => {
const res = await api.get('/users')
const result = ContractValidator.validate(res, userListContract)
assertTrue(result.passed, `契约验证失败: ${result.errors.join('; ')}`)
})
it('GET /users/:id_符合契约定义', 0, async () => {
const res = await api.get('/users/u-001')
const result = ContractValidator.validate(res, userDetailContract)
assertTrue(result.passed, `契约验证失败: ${result.errors.join('; ')}`)
})
it('契约变更检测_新增字段不影响旧契约', 0, async () => {
const res = await api.get('/users/u-001')
const data = res.data as Record<string, Object>
// 新增字段不应导致旧契约失败
assertEqual(res.statusCode, 200)
assertNotNull(data['id'])
assertNotNull(data['name'])
})
})
}
3.3 完整示例:Mock Server集成测试
在本地搭建Mock Server,不依赖真实后端,测试接口调用逻辑:
// MockHttpServer.ets - 本地Mock HTTP服务器
import http from '@ohos.net.http'
import { EventEmitter } from '@ohos.events.emitter'
export interface MockRoute {
method: string
path: string
handler: (params: Record<string, string>, body: object | null) => MockResponse
}
export interface MockResponse {
statusCode: number
data: object
delay?: number // 模拟延迟(毫秒)
}
export class MockHttpServer {
private routes: Array<MockRoute> = []
private requestLog: Array<{ method: string; path: string; timestamp: number }> = []
// 注册路由
register(method: string, path: string, handler: MockRoute['handler']): void {
this.routes.push({ method, path, handler })
}
// 处理请求(模拟HTTP请求,不走真实网络)
async handleRequest(method: string, path: string, params?: Record<string, string>, body?: object): Promise<MockResponse> {
// 记录请求日志
this.requestLog.push({ method, path, timestamp: Date.now() })
// 查找匹配的路由
const route = this.routes.find(r => r.method === method && r.path === path)
if (!route) {
return { statusCode: 404, data: { error: 'Not Found' } }
}
// 模拟延迟
if (route.handler(params || {}, body || null).delay) {
await new Promise<void>(resolve => setTimeout(resolve, route.handler(params || {}, body || null).delay))
}
return route.handler(params || {}, body || null)
}
// 获取请求日志
getRequestLog(): Array<{ method: string; path: string; timestamp: number }> {
return [...this.requestLog]
}
// 清空请求日志
clearLog(): void {
this.requestLog = []
}
// 重置所有路由
reset(): void {
this.routes = []
this.requestLog = []
}
}
// ==================== 完整的Mock Server集成测试 ====================
import { describe, it, beforeAll, beforeEach, assertEqual, assertNotNull, assertTrue } from '@ohos/hypium'
import { MockHttpServer } from '../MockHttpServer'
import { ApiClient } from '../ApiClient' // 业务代码中的API客户端
export default function mockServerIntegrationTest() {
describe('Mock Server集成测试', () => {
let mockServer: MockHttpServer
let apiClient: ApiClient
beforeAll(() => {
mockServer = new MockHttpServer()
// 注册用户相关Mock路由
mockServer.register('GET', '/users', (params, body) => ({
statusCode: 200,
data: {
items: [
{ id: 'mock-001', name: '测试用户A', email: 'a@test.com' },
{ id: 'mock-002', name: '测试用户B', email: 'b@test.com' }
],
total: 2,
page: 1
}
}))
mockServer.register('GET', '/users/:id', (params, body) => {
if (params['id'] === 'mock-001') {
return {
statusCode: 200,
data: { id: 'mock-001', name: '测试用户A', email: 'a@test.com', createdAt: '2024-01-01' }
}
}
return { statusCode: 404, data: { error: '用户不存在' } }
})
mockServer.register('POST', '/users', (params, body) => {
const userData = body as Record<string, Object>
return {
statusCode: 201,
data: { id: 'mock-new', name: userData['name'], email: userData['email'] }
}
})
// 模拟服务器错误
mockServer.register('GET', '/error', (params, body) => ({
statusCode: 500,
data: { error: 'Internal Server Error' }
}))
// 模拟慢接口
mockServer.register('GET', '/slow', (params, body) => ({
statusCode: 200,
data: { message: 'slow response' },
delay: 3000 // 3秒延迟
}))
// 用Mock Server创建ApiClient
apiClient = new ApiClient(mockServer)
})
beforeEach(() => {
mockServer.clearLog()
})
it('获取用户列表_正常返回', 0, async () => {
const result = await apiClient.getUsers()
assertEqual(result.items.length, 2)
assertEqual(result.items[0].id, 'mock-001')
})
it('获取用户详情_存在的用户', 0, async () => {
const user = await apiClient.getUserById('mock-001')
assertNotNull(user)
assertEqual(user.name, '测试用户A')
})
it('获取用户详情_不存在的用户抛出异常', 0, async () => {
try {
await apiClient.getUserById('non-exist')
assertTrue(false, '应该抛出异常')
} catch (e) {
assertTrue((e as Error).message.includes('404'))
}
})
it('创建用户_返回新用户信息', 0, async () => {
const newUser = await apiClient.createUser({
name: '新用户',
email: 'new@test.com'
})
assertEqual(newUser.id, 'mock-new')
assertEqual(newUser.name, '新用户')
})
it('服务器错误_抛出业务异常', 0, async () => {
try {
await apiClient.request('GET', '/error')
assertTrue(false, '应该抛出异常')
} catch (e) {
assertTrue((e as Error).message.includes('500'))
}
})
it('请求日志_记录所有请求', 0, async () => {
await apiClient.getUsers()
await apiClient.getUserById('mock-001')
const log = mockServer.getRequestLog()
assertEqual(log.length, 2)
assertEqual(log[0].method, 'GET')
assertEqual(log[0].path, '/users')
})
it('慢接口_超时处理', 0, async () => {
// ApiClient设置2秒超时,Mock返回3秒延迟
const timeoutClient = new ApiClient(mockServer, { timeout: 2000 })
try {
await timeoutClient.request('GET', '/slow')
assertTrue(false, '应该超时')
} catch (e) {
assertTrue((e as Error).message.includes('超时') || (e as Error).message.includes('timeout'))
}
})
})
}
四、踩坑与注意事项
坑点1:Content-Type不匹配
你发了application/json的请求体,但服务端期望的是application/x-www-form-urlencoded。请求能发出去,但服务端解析不了参数,返回400。这种问题在接口文档不完善的情况下特别容易踩。
建议:每个接口测试用例都明确指定Content-Type,不要依赖默认值。如果接口文档没写,先抓包看真实请求的Content-Type。
坑点2:Token过期导致批量测试失败
登录获取Token,然后跑10个接口测试。跑到第5个,Token过期了,后面5个全401。你以为接口有问题,其实是Token的问题。
解决方案:在beforeEach中检查Token是否有效,过期了自动重新登录。或者使用长效测试Token(仅限测试环境)。
坑点3:响应体解析失败没处理
JSON.parse可能抛异常——服务端返回的不是合法JSON(比如HTML错误页面)。如果你没处理这个异常,测试直接崩溃,连错误信息都看不到。
// 错误写法:直接解析
const data = JSON.parse(response.result as string)
// 正确写法:包裹try-catch
let data: object | null = null
try {
data = JSON.parse(response.result as string)
} catch (e) {
// 记录原始响应,方便排查
console.error(`响应解析失败,原始内容: ${response.result}`)
}
assertNotNull(data, '响应体应为合法JSON')
坑点4:测试环境与生产环境接口行为不一致
测试环境接口返回{code: 0, data: {...}},生产环境返回{status: "success", result: {...}}。你的测试全绿,但上线就崩。
建议:契约测试同时跑测试环境和预发布环境,确保两个环境的接口行为一致。如果条件允许,直接对生产环境跑只读接口的测试(GET请求)。
坑点5:Mock Server行为与真实服务不一致
Mock返回的数据格式和真实服务不一致——Mock里字段名是userId,真实服务是user_id。测试全绿,但对接真实服务就炸。
解决方案:Mock数据从真实服务的响应中录制,而不是手写。定期用真实服务的响应更新Mock数据,保持一致性。
坑点6:并发请求导致测试数据冲突
两个测试用例同时操作同一条数据——A用例在删除,B用例在查询,B查到了A正在删的数据,结果不确定。
建议:每个测试用例使用唯一的数据标识(如UUID),不要共享测试数据。如果必须共享,用串行执行(@Serial装饰器)避免并发冲突。
坑点7:HTTP资源泄漏
http.createHttp()创建的请求对象用完必须destroy()。如果你在测试中创建了大量请求对象但没释放,内存会持续增长,最终导致应用崩溃。
建议:使用try-finally确保资源释放,或者封装一个自动释放的请求工具类。
五、HarmonyOS 6适配说明
API差异表
| 功能/接口 | HarmonyOS 5 | HarmonyOS 6 | 变更说明 |
|---|---|---|---|
| HTTP客户端 | @ohos.net.http | @ohos.net.http | 新增请求拦截器 |
| 请求构造 | 手动拼接 | RequestBuilder | 流式请求构造器 |
| 响应验证 | 手动断言 | ResponseValidator | 内置响应验证器 |
| Mock支持 | 需自实现 | @ohos.net.httpMock | 官方Mock模块 |
| 契约测试 | 需自实现 | @ohos/apiContract | 官方契约测试框架 |
行为变更
-
RequestBuilder流式构造:不再需要手动拼接URL参数和请求体,使用链式调用构建请求:
new RequestBuilder().url('/users').method('GET').header('Authorization', token).build()。 -
内置Mock模块:HarmonyOS 6提供了
@ohos.net.httpMock,可以拦截HTTP请求并返回预设响应,不需要自己搭建Mock Server。 -
ResponseValidator:内置响应验证器,支持JSON Schema验证、状态码验证、响应时间验证,不再需要手写验证逻辑。
适配代码
// HarmonyOS 6 API测试写法
import { describe, it, beforeAll, assertEqual, assertTrue } from '@ohos/hypium'
import { RequestBuilder } from '@ohos.net.http'
import { httpMock, MockRoute } from '@ohos.net.httpMock'
import { ApiContract, validateContract } from '@ohos/apiContract'
export default function harmonyOS6ApiTest() {
describe('HarmonyOS 6 API测试', () => {
beforeAll(() => {
// 注册Mock路由
httpMock.register({
method: 'GET',
path: '/api/users',
response: { statusCode: 200, data: { items: [{ id: '1', name: 'Test' }] } }
})
})
it('RequestBuilder_流式构造请求', 0, async () => {
const request = new RequestBuilder()
.url('https://api.example.com/users')
.method('GET')
.header('Authorization', 'Bearer test-token')
.timeout(5000)
.build()
const response = await request.execute()
assertEqual(response.statusCode, 200)
})
it('契约验证_自动校验响应', 0, async () => {
const contract: ApiContract = {
endpoint: '/api/users',
method: 'GET',
schema: {
type: 'object',
required: ['items'],
properties: { items: { type: 'array' } }
}
}
const result = await validateContract(contract)
assertTrue(result.valid, `契约验证失败: ${result.errors?.join(', ')}`)
})
})
}
六、总结
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐⭐ |
| 使用频率 | ⭐⭐⭐⭐⭐ |
| 重要程度 | ⭐⭐⭐⭐⭐ |
API测试是性价比最高的测试类型——不依赖UI,执行快,覆盖面广。核心就四件事:请求构造要对、响应验证要全、契约测试要跑、Mock Server要靠谱。契约测试是其中最关键的,它强制接口变更有迹可循,避免了"改了接口没人知道"的灾难。Mock Server让你不依赖真实后端也能测,但一定要保证Mock行为和真实服务一致。接口测试写好了,联调时的痛苦至少减少80%。
- 点赞
- 收藏
- 关注作者
评论(0)