尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

REST Assured接口自动化测试分层实践:从环境搭建到工程维护

REST Assured接口自动化测试分层实践:从环境搭建到工程维护 先说个碰了无数次的小事标题里的REST-assure其实是手误正确拼写是REST Assured。这个拼写问题在技术社区里特别常见但不影响它是Java生态里做接口自动化测试最顺手的库。我这次拿它写了个分层的小练习正好把从环境搭建、代码结构到日常维护的完整思路捋一遍适合刚接触接口自动化测试、想从脚本能跑过渡到工程能维护的朋友参考。接口自动化测试说起来不复杂对被测系统的HTTP接口发请求校验返回的状态码、响应体、响应时间跑在CI里每天盯回归。但真正写好它的人不多因为大多数人把它当脚本写而不是当代码工程写。这篇博文就围绕REST Assured Java TestNG这套组合重点讲清楚分层这件事——POJO层、接口封装层、数据层、用例层各管什么代码怎么写坑怎么避。1. 为什么是REST Assured先搞清楚工具选型的底层逻辑1.1 它到底解决了什么痛点在没有REST Assured之前用Java写接口测试最痛苦的事是你要用HttpClient拼URL、拼Header、拼请求体再把响应的JSON字符串手动解析成对象然后一个个字段去比较。哪怕只测一个最简单的登录接口光样板代码就得写三四十行而且每多一个接口这段代码就要复制粘贴一份改个参数要全局搜索替换。REST Assured做的事情是把发HTTP请求、解析响应、写断言这三件事压缩成一段接近自然语言的链式调用。它本身是一个基于Groovy的DSL但在Java里用起来完全没有违和感底层帮你处理了HTTP连接、JSON序列化、JSONPath提取这些脏活。你只需要关心接口的业务逻辑长什么样。另外它还解决了断言难写的问题。原生的JUnit断言只能比较两个对象是否相等但接口测试需要的断言往往是状态码是不是201返回的列表里有没有name等于Tom的元素响应时间是不是小于2秒这类结构化校验。REST Assured内置了Hamcrest Matcher可以直接对JSON结构做链式断言这个体验非常接近Postman里的断言但比Postman更适合写进代码仓库做版本管理。1.2 与HttpClient、OkHttp、Postman的对比很多人会问Java里做HTTP请求明明有HttpClient和OkHttp为什么要再学一个REST Assured这个问题的答案其实很简单那些库是给业务代码调用第三方接口用的而REST Assured是给测试代码验证接口正确性用的。我把几个方案的差异整理成了一张表方便大家根据自己的场景选方案适合场景优势劣势REST Assured接口自动化测试链式DSL流畅、断言体系完整、JSONPath提取方便、社区教程多依赖Groovy底层出问题要懂一点Groovy和HTTP原理HttpClient业务代码调用HTTP灵活、底层可控、性能好写测试用例样板代码太多断言要自己造轮子OkHttpAndroid/高性能场景连接池、拦截器、性能优秀偏底层纯测试场景效率不高Postman Newman手工调试、快速冒烟上手快、图形化界面友好脚本表达能力弱、不好做复杂数据驱动、代码仓库协作不方便一句话总结如果你们团队是Java技术栈又要长期维护一套接口自动化测试REST Assured基本是首选。如果你只是想临时验证一个接口通不通用Postman没必要上代码。1.3 工程依赖与版本确认先说一个容易踩的坑REST Assured在3.0以前groupId是com.jayway.restassured3.0之后改成了io.rest-assured。现在新项目统一用后者网上很多老教程还写着旧的依赖坐标复制进去会导致依赖下载失败。我这次练习用的版本组合是JDK 8 REST Assured 5.4.0 TestNG 7.10.2 Jackson 2.17.1都是当前比较稳定的版本。Maven依赖配置如下properties rest-assured.version5.4.0/rest-assured.version testng.version7.10.2/testng.version jackson.version2.17.1/jackson.version /properties dependencies !-- REST Assured 核心库 -- dependency groupIdio.rest-assured/groupId artifactIdrest-assured/artifactId version${rest-assured.version}/version scopetest/scope /dependency !-- 测试框架用 TestNG 而不是 JUnit后面解释为什么 -- dependency groupIdorg.testng/groupId artifactIdtestng/artifactId version${testng.version}/version scopetest/scope /dependency !-- JSON 序列化与反序列化 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version${jackson.version}/version scopetest/scope /dependency /dependencies为什么用TestNG而不用JUnit两个框架都能跑测试但TestNG天然支持DataProvider数据驱动、groups分组、dependsOnMethods依赖测试这些特性非常适合接口自动化测试的场景。JUnit 5虽然也能做参数化测试但整体设计还是偏单元测试分工没那么清晰。当然这个选择不是绝对的关键是团队统一。2. 分层设计让测试代码从一次性脚本变成可维护工程2.1 不分层会怎样我在不少团队里见过所谓的接口自动化测试代码打开就是一个几万行的测试类一个方法对应一个接口请求参数写死在方法里断言直接对着JSON字符串做contains判断。这种脚本在接口数量少于20个的时候还能勉强维护一旦接口涨到上百个就会出现几个非常要命的问题第一个接口地址散落在一堆测试方法里后端改个URL前缀你要全局搜索替换改漏一个就产生一条虚假的失败用例第二个请求体构造和接口调用逻辑混在断言里测试代码读起来极其痛苦根本分不清哪些是前置准备、哪些是真正要验证的东西第三个接口返回的数据结构一变所有涉及这个接口的用例都要跟着改但同一个接口可能被十几个用例调用改起来就是一场灾难。分层想解决的问题本质上是让变化的成本可控。接口地址变了你只改一处配置请求体数据结构变了你只改POJO和封装方法测试数据变了你只改数据文件。测试用例本身应该尽量稳定因为它描述的是业务规则而不是HTTP协议细节。2.2 四层结构职责划分我做这个练习时把工程分成了四层加一个全局配置对应关系如下层级包名职责典型内容测试用例层cases描述业务场景、编排操作步骤、写断言新增用户成功、查询用户列表接口封装层api把HTTP请求封装成可复用的方法UserApiClient类方法对应增删改查数据准备层utils / testdata构造测试数据隔离用例与数据细节DataProvider、JSON数据文件实体层pojo定义请求/响应的Java对象结构User、LoginRequest、LoginResponse全局配置config管理BaseURL、超时、公共HeaderTestConfig类依赖方向严格自上而下cases依赖api、utils、pojoapi依赖pojo和configutils依赖pojo和config。禁止跨层调用比如cases直接去拼HTTP请求或者api层里写断言逻辑这些都是分层腐烂的征兆。2.3 包结构与依赖方向实际的包结构长这样src/test/java ├── config/ │ └── TestConfig.java ├── pojo/ │ ├── User.java │ ├── LoginRequest.java │ └── LoginResponse.java ├── api/ │ └── UserApiClient.java ├── utils/ │ ├── DataProviderUtils.java │ └── LogUtils.java └── cases/ ├── UserApiTest.java └── LoginTest.java src/test/resources/ ├── testdata/ │ ├── users.json │ └── login.json └── testng.xml画一下依赖方向会更容易理解cases指向api、utils、pojoapi指向pojo和configutils指向pojo和config。config是整个结构的地基任何一层都可以读取全局配置但只有config允许直接操作REST Assured的全局静态变量。这种设计带来的直观好处是你拿到一个陌生项目从类名和包路径就能判断出某个东西该放哪里、该找谁要数据不需要把几十个文件全部翻一遍。3. 落地实操手把手把每一层代码写出来3.1 准备一个可复现的测试环境写测试代码之前你需要一个被测接口。这里我推荐用json-server在本地快速起一个REST API它把一个JSON文件变成完整的增删改查接口用来练习接口自动化测试非常合适而且完全可控。先安装并准备数据文件npm install -g json-server在项目根目录新建db.json{ users: [ { id: 1, name: Tom, email: tomexample.com, job: Engineer }, { id: 2, name: Jerry, email: jerryexample.com, job: QA } ] }启动服务json-server --watch db.json --port 8080启动完成后http://localhost:8080/api/users就是一个标准的REST接口支持GET、POST、PUT、DELETE。当然你也可以用reqres.in这类公开测试接口练手但本地服务不依赖外网跑CI的时候更稳定。3.2 POJO层用对象代替Map让代码可读且可校验POJO层做的事情很简单把接口请求和响应里的JSON结构映射成Java对象。为什么要这么做你可以用Map乱装但Map的key拼错只有在运行时才会暴露而POJO有编译期类型检查字段名也能获得IDE的自动补全提示。比如用户对象package com.example.pojo; public class User { private Integer id; private String name; private String email; private String job; public User() { } public User(String name, String job) { this.name name; this.job job; } public Integer getId() { return id; } public void setId(Integer id) { this.id id; } public String getName() { return name; } public void setName(String name) { this.name name; } public String getEmail() { return email; } public void setEmail(String email) { this.email email; } public String getJob() { return job; } public void setJob(String job) { this.job job; } }注意POJO一定要保留无参构造方法因为Jackson在反序列化时默认会调用无参构造器来创建对象如果你只写了带参构造方法反序列化会直接报错。这个问题我在后面常见问题里还会详细说。3.3 接口封装层把HTTP细节关进黑盒接口封装层是整个分层的核心。它把向哪个URL发什么方法、带什么Header、传什么参数这些HTTP细节全部封装起来向上层暴露的只有语义化的方法名比如createUser、getUserList。一个典型的接口封装类长这样package com.example.api; import com.example.pojo.User; import io.restassured.response.Response; import static io.restassured.RestAssured.given; public class UserApiClient { private static final String USER_PATH /users; public Response createUser(User user) { return given() .log().ifValidationFails() .contentType(application/json) .body(user) .when() .post(USER_PATH); } public Response getUserList(int page, int perPage) { return given() .queryParam(page, page) .queryParam(per_page, perPage) .when() .get(USER_PATH); } public Response getUserById(int id) { return given() .pathParam(id, id) .when() .get(USER_PATH /{id}); } public Response updateUser(int id, User user) { return given() .contentType(application/json) .pathParam(id, id) .body(user) .when() .put(USER_PATH /{id}); } public Response deleteUser(int id) { return given() .pathParam(id, id) .when() .delete(USER_PATH /{id}); } }这里我统一返回Response对象不在封装层做断言。原因是同一个接口在不同场景下可能有不同的期望结果比如创建用户要断言201权限不足时要断言403断言应该交给用例层按场景去写封装层只负责把请求发出去、把响应带回来。.log().ifValidationFails()这行建议保留它的意思是只有断言失败时才打印完整的请求和响应日志。平时测试通过时日志干净排查问题时又能拿到完整信息很实用。3.4 数据准备层数据驱动让一条用例覆盖多组入参接口测试里最常见的场景是同一套流程换不同的入参验证不同的结果。比如创建用户你想测正常姓名、超长姓名、空姓名、特殊字符姓名如果每种情况写一个测试方法代码会变得非常冗余。TestNG的DataProvider就是专门解决这个问题的。数据准备层我拆成了两个部分一部分是DataProviderUtils类负责向用例层提供参数化数据另一部分是testdata目录下的JSON文件负责存放测试数据。先看JSON数据文件users.json{ users: [ { name: Tom, job: Engineer }, { name: Jerry, job: QA } ] }再来看读取这个文件的DataProviderpackage com.example.utils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.testng.annotations.DataProvider; import java.io.File; import java.io.IOException; public class DataProviderUtils { private static final ObjectMapper MAPPER new ObjectMapper(); DataProvider(name userData) public static Object[][] userData() throws IOException { JsonNode root MAPPER.readTree(new File(src/test/resources/testdata/users.json)); JsonNode users root.get(users); Object[][] result new Object[users.size()][2]; for (int i 0; i users.size(); i) { result[i][0] users.get(i).get(name).asText(); result[i][1] users.get(i).get(job).asText(); } return result; } }这个做法的好处是测试数据跟测试代码完全分离。以后想加一组测试数据只需要改JSON文件不用重新编译代码。数据量大了之后还可以把JSON文件替换成Excel、YAML或者数据库对用例层完全透明。3.5 测试用例层只关心业务场景不关心HTTP细节用例层是离业务最近的一层也是代码量最少的一层。因为前面的分层已经把脏活都干完了用例层要做的事情就是准备数据、调用接口、写断言。package com.example.cases; import com.example.api.UserApiClient; import com.example.config.TestConfig; import com.example.pojo.User; import com.example.utils.DataProviderUtils; import io.restassured.response.Response; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; import static org.hamcrest.Matchers.*; public class UserApiTest { private UserApiClient userApiClient; BeforeClass public void setUp() { TestConfig.init(); userApiClient new UserApiClient(); } Test(dataProvider userData, dataProviderClass DataProviderUtils.class) public void testCreateUser(String name, String job) { User user new User(name, job); Response response userApiClient.createUser(user); response.then() .statusCode(201) .body(name, equalTo(name)) .body(job, equalTo(job)) .body(id, notNullValue()) .body(createdAt, notNullValue()); } Test public void testGetUserList() { Response response userApiClient.getUserList(1, 2); response.then() .statusCode(200) .body(page, equalTo(1)) .body(per_page, equalTo(2)) .body(data, hasSize(2)) .body(data[0].name, notNullValue()); } Test public void testGetUserById() { Response response userApiClient.getUserById(1); response.then() .statusCode(200) .body(data.id, equalTo(1)) .body(data.email, containsString()); } Test public void testDeleteUser() { // 先创建一个用户拿到新生成的id再删除 User temp new User(Temp, TempJob); Response createResponse userApiClient.createUser(temp); int userId createResponse.jsonPath().getInt(id); Response deleteResponse userApiClient.deleteUser(userId); deleteResponse.then().statusCode(200); Response getResponse userApiClient.getUserById(userId); getResponse.then().statusCode(404); } }这里有几个细节值得说一下。dataProviderClass DataProviderUtils.class是因为DataProvider方法定义在另一个类里需要显式指定类名。data[0].name这种写法是JSONPath的数组下标访问方式REST Assured会直接用JSONPath解析非常强大。testDeleteUser演示了用例间的数据依赖处理方式先调用接口创建数据拿到返回的id再基于这个id做后续操作。这是接口自动化测试里非常典型的模式数据不是预先准备好的固定数据而是运行时动态生成的。3.6 全局配置BaseURL和超时统一管理最后是全局配置类。它的作用是集中管理REST Assured的公共设置避免在每个测试方法里重复写BaseURL、超时时间、公共Header之类的代码。package com.example.config; import io.restassured.RestAssured; import io.restassured.config.DecoderConfig; import io.restassured.config.HttpClientConfig; import io.restassured.config.RestAssuredConfig; public class TestConfig { private TestConfig() { } public static final String BASE_URL http://localhost:8080/api; public static void init() { RestAssured.baseURI BASE_URL; // 连接和读取超时设置单位毫秒 RestAssured.config new RestAssuredConfig() .httpClient(new HttpClientConfig() .setParam(http.connection.timeout, 5000) .setParam(http.socket.timeout, 8000)) .decoderConfig(new DecoderConfig() .defaultContentCharset(UTF-8)); } }切换环境的时候只需要改这一个文件里的BASE_URL全工程的用例都会跟着切换。这就是分层的价值把易变的、公共的东西从具体业务逻辑里剥离出来。4. 生成报告与日常维护测试跑完不是结束4.1 用TestNG组织用例和报告TestNG除了数据驱动还有一个很实用的能力是支持testng.xml文件管理用例。你可以按模块分组、指定执行顺序、设置多线程并发。在src/test/resources/testng.xml里!DOCTYPE suite SYSTEM https://testng.org/testng-1.0.dtd suite name接口自动化测试套件 verbose1 parallelmethods thread-count4 test name用户模块 classes class namecom.example.cases.UserApiTest/ class namecom.example.cases.LoginTest/ /classes /test /suite配置了parallelmethods之后TestNG会把测试方法放到4个线程里并发执行跑完一套用例的时间能明显缩短。但要注意并发执行要求用例之间没有数据依赖如果你的用例共享了同一个测试账号或者互相依赖创建的数据就不要轻易开并发否则会出现数据竞争导致的随机失败。运行结束之后测试报告默认生成在target/surefire-reports目录下。其中emailable-report.html是一个可以直接用浏览器打开的HTML报告包含每个用例的执行状态、耗时和失败堆栈发给团队看很方便。为了让Maven正确运行TestNG还需要在pom.xml里加上surefire插件配置build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.2.5/version configuration suiteXmlFiles suiteXmlFilesrc/test/resources/testng.xml/suiteXmlFile /suiteXmlFiles /configuration /plugin /plugins /build这样直接在项目根目录执行mvn test就能跑完整套接口测试CI接入也就是多一条命令的事。4.2 日志输出别全量打印按需打印接口自动化测试排障的大部分时间花在看请求和响应上。REST Assured提供了log().all()方法可以在控制台完整打出请求头、请求体、响应头、响应体非常直观。但我不建议在所有用例上无脑加log().all()因为一套用例可能几千个请求全量打印会让日志文件迅速膨胀反而淹没有用的信息。我的习惯是分层控制在接口封装层统一加log().ifValidationFails()这样只有在断言失败的时候才会打印完整的HTTP交互记录。如果某个用例你要调试临时改成log().all()调试完再改回去。如果你觉得控制台日志不够用可以自己写个简单的LogUtils把响应body写入本地文件package com.example.utils; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; public class LogUtils { private LogUtils() { } public static void saveResponseToFile(String fileName, String content) { try { Path path Paths.get(target/responses/ fileName); Files.createDirectories(path.getParent()); Files.write(path, content.getBytes()); } catch (IOException e) { e.printStackTrace(); } } }失败的时候把响应体存成文件用IDE打开格式化一下对比字段差异会比在控制台里翻一堆日志舒服得多。4.3 分层之后日常改代码的动作会变成什么样分层的直接回报体现在日常维护中。我说几个真实场景大家感受一下后端接口地址从/api/users改成了/api/v2/users你只需要去UserApiClient里改一个常量或者去TestConfig里改BaseURL不用碰任何用例。接口响应里新增了一个字段比如用户对象加了个phone字段。这时只需要在User这个POJO类里加一个字段和对应的getter/setter原来所有用例的断言都不受影响。需要新增一个查询用户订单的接口测试流程是在POJO包下新建Order类在API包下新建OrderApiClient类在数据目录加订单测试数据在cases包下新建OrderTest类。每一层都是独立的增删改不会牵连其他模块。如果当初不分层这些改动里每一个都可能演变成一次全局搜索替换加提心吊胆的回归。5. 高频问题排查实录你大概率会踩的坑5.1 依赖导入后报错NoClassDefFoundError: groovy/lang/GroovyObject这个错误我见过太多次了尤其是在已有的Java项目里引入REST Assured时。REST Assured底层依赖Groovy但它在Maven里通常不会主动拉取Groovy核心库如果你的项目里恰好有旧版本的Groovy依赖或者IDE缓存的classpath不完整就会在运行时抛出这个异常。排查思路是先用Maven命令查看依赖树mvn dependency:tree -Dincludesorg.codehaus.groovy如果发现Groovy版本冲突在pom.xml里排除掉旧的Groovy依赖或者在dependencyManagement中统一Groovy版本。最省事的办法是执行一次mvn clean install再看有时只是IDE的索引缓存出了问题。5.2 Jackson反序列化LocalDateTime失败JSON里常见的createTime字段如果你在POJO里对应的是LocalDateTime类型直接用REST Assured的response.jsonPath().getObject(json, YourClass.class)时会报错因为Jackson默认不处理Java 8的时间类型。解决办法是引入jackson-datatype-jsr310依赖并注册JavaTimeModuledependency groupIdcom.fasterxml.jackson.datatype/groupId artifactIdjackson-datatype-jsr310/artifactId version${jackson.version}/version scopetest/scope /dependencyObjectMapper mapper new ObjectMapper(); mapper.registerModule(new JavaTimeModule());如果不关心时间字段的反序列化另一个偷懒的做法是把POJO里的时间字段类型改成String这样不需要任何额外配置。接口测试的主要目标是验证接口行为不是去做全字段的对象映射时间字段用字符串断言反而更直观。5.3 控制台打印响应中文乱码REST Assured从5.x开始默认使用UTF-8解码但如果你接的是老系统响应头里Content-Type没有带charset或者接口本身返回的是GBK编码控制台就会输出乱码。解决办法是在全局配置里指定解码字符集RestAssured.config RestAssured.config() .decoderConfig(new DecoderConfig() .defaultContentCharset(UTF-8));还有一种隐蔽情况接口返回的数据经过了gzip压缩而客户端没有正确处理Content-Encoding: gzip导致拿到的是压缩字节流然后按字符串打印看起来也像乱码。这种情况需要检查响应头里是否有Content-Encoding: gzip有的话确认REST Assured是否自动解压必要时手动配置given() .config(RestAssured.config() .decoderConfig(new DecoderConfig() .contentDecoders(ContentDecoder.DEFLATE, ContentDecoder.GZIP))) .when() .get(...);5.4 测试环境是自签名HTTPS证书请求一直报SSLHandshakeException这个问题在对接公司内部测试环境时非常常见测试环境为了省钱用自签名证书REST Assured默认会校验SSL证书然后直接拒绝连接。如果你明确知道目标环境是测试环境可以在请求里放开HTTPS校验given() .relaxedHTTPSValidation() .when() .get(https://test.example.com/api/users);.relaxedHTTPSValidation()是REST Assured专门为测试提供的API它相当于告诉你这个环境的证书我不管了放心发请求。但必须强调这只应该用在测试环境绝不允许在生产环境或者任何涉及真实用户数据的场景里使用否则等于把安全校验的底裤给脱了。5.5 断言失败时不知道接口到底返回了什么新手最容易遇到的情况是断言写好了测试挂了但只看到一个Expected: 201, Actual: 500完全不知道服务器为什么返回500。这是因为你只做了状态码断言但没有把响应内容打出来。如果你在接口封装层按我前面说的统一加了.log().ifValidationFails()那断言失败时REST Assured会自动把请求和响应完整打印出来这个问题基本不会出现。另外我还会把复杂的断言拆成多个独立步骤// 不推荐一次性断言太多挂了不好定位 response.then() .statusCode(200) .body(code, equalTo(0)) .body(data.size(), greaterThan(0)) .body(data[0].name, equalTo(Tom)); // 推荐先验证关键信息再逐层验证字段 response.then().statusCode(200); response.then().body(code, equalTo(0)); response.then().body(data, hasSize(3)); response.then().body(data[0].name, equalTo(Tom));这样每一步失败时报错信息就能精确告诉你是哪一层出了问题而不是一条长链条里猜。5.6 高频问题速查表现象可能原因解决方案NoClassDefFoundError: groovy/lang/GroovyObjectGroovy依赖缺失或版本冲突检查依赖树排除旧版Groovyclean后重建LocalDateTime反序列化失败Jackson缺少jsr310模块引入jackson-datatype-jsr310并注册JavaTimeModule中文乱码字符集不匹配配置decoderConfig指定UTF-8检查Content-EncodingSSLHandshakeException测试环境自签名证书测试环境使用relaxedHTTPSValidation()断言失败但看不到响应内容缺少日志配置统一加log().ifValidationFails()JSON数据量大时断言很难定位断言链条太长拆分断言步骤逐层验证用例并发执行随机失败测试数据互相污染关闭并发或用独立数据隔离6. 写在最后的个人建议我做接口自动化测试从最早的HttpClient手写请求到后来全面切到REST Assured最大的感受是写测试代码跟写业务代码一样架构决定了你后三个月的幸福感。分层看起来多写了好几个类但等接口从十几个涨到上百个你就明白这些工夫到底值不值。最后分享一个小习惯每次跑完测试我会顺手把失败接口的响应body存成文件用IDE打开对照字段比翻控制台日志直观得多。另外REST Assured的官方文档和源码注释写得相当好遇到用法问题先查这两处解决问题的速度往往比搜搜索引擎快。这套Java分层的练习框架我已经在多个项目里复用过了核心代码的量级基本没变变的只是POJO和ApiClient的数量这大概就是分层设计最好的证明。
返回列表