文章 Kelly Huang · 三月 17 10m read

使用 Mustache 模板自定义 API 测试代码生成

在上一篇文章中,我们介绍了IrisOASTestGen——一个基于OpenAPI 2.0规范为InterSystems IRIS生成REST API测试代码的工具。该文章展示了如何使用OpenAPI Generator附带的默认模板来搭建测试用例。本文将聚焦于接下来的自然步骤**:自定义生成的测试代码**。通过使用Mustache模板扩展代码生成逻辑,我们可以表达更丰富的语义、实现CRUD感知测试,并创建更有意义的测试套件。本文的示例将修改IrisOASTestGen,为createPerson操作生成测试,包括遍历所有预期响应。这将展示如何使用OpenAPI规范中的自定义字段来驱动Mustache模板内的条件渲染。


OpenAPI扩展以支持CRUD操作

为了使代码生成支持CRUD操作,OpenAPI规范可以在vendorExtensions中包含自定义字段。这些自定义字段作为标志,Mustache模板可以检测到它们。以下是createPerson操作的相关代码片段:

"post": {
  "tags": ["Person"],
  "x-crud-operation": "",
  "x-crud-operation-create": "",
  "summary": "Create a new Person record",
  "operationId": "createPerson",
  ...
  "responses": {
    "201": {
      "description": "Person created successfully",
      "schema": { "$ref": "#/definitions/Person" },
      "headers": {
        "Location": {
          "description": "The full URI of the newly created resource",
          "type": "string"
        }
      }
    },
    "400": {
      "description": "Invalid input",
      "schema": { "$ref": "#/definitions/Error" }
    }
  }
}

两个突出的自定义字段是:

  • x-crud-operation:将操作标记为CRUD操作。
  • x-crud-operation-create:指定操作对应于CRUD的创建步骤。

这些自定义字段将被Mustache模板用来决定应为该特定操作生成哪些代码块。


IrisOASTestGen如何使用Mustache模板

IrisOASTestGen在底层依赖于OpenAPI Generator,这意味着所有生成的文件最终都是通过Mustache模板生成的。有三个模板文件参与代码生成:

  • api.mustache:为API操作生成测试代码
  • model.mustache:生成测试模型类
  • HttpUtils.mustache:生成一个轻量级的HTTP工具类

为了本文的目的,我们将只修改api.mustache,因为我们的目标是自定义操作级别的测试生成。为了简洁起见,示例中将只完整实现createPerson操作。


理解api.mustache模板

以下是用于生成测试用例的完整Mustache模板。此文件中的几个区域响应前面显示的自定义供应商扩展字段。

Import {{modelPackage}}

Class {{apiPackage}}.Tests{{classname}} Extends %UnitTest.TestCase {

/// The base URL of the API
Parameter BasePath = "{{host}}{{basePath}}";

/// Preparation method for the whole test
Method OnBeforeAllTests() As %Status {
    Return $$$OK
}

/// Cleanup method for the whole test
Method OnAfterAllTests() As %Status {
    Return $$$OK
}

/// Preparation method for each test
Method OnBeforeOneTests(testname As %String) As %Status {
    Return $$$OK
}

/// Cleanup method for each test
Method OnAfterOneTest() As %Status {
    Do ##Class(dc.musketeers.irisOasTestGenDemo.personApi.Person).%KillExtent()
    Return $$$OK
}

{{#operations}}
{{#operation}}
{{#vendorExtensions.x-crud-operation}}
{{#vendorExtensions.x-crud-operation-create}}
{{#responses}}
/// Test for {{operationId}} with response code {{code}}
Method Test{{#lambda.pascalcase}}{{operationId}}{{/lambda.pascalcase}}ResponseCode{{code}}() {
{{#bodyParams}}
    Set resource = ##Class({{dataType}}).%New()
    #; // Set here the resource properties to test the API
    #; Set resource.property = "some value"
    #; ...
{{/bodyParams}}
    // Execute the operation
    Set httpResponse = ..{{#lambda.pascalcase}}{{operationId}}{{/lambda.pascalcase}}(resource)

    // Parse the JSON Response
    Set responseString = httpResponse.Data.Read()
    Set response = ##Class({{baseType}}).%New()
    Set sc = response.%JSONImport(responseString)

    // Response Body Type Check
    Do $$$AssertStatusOK(sc, "{{#lambda.pascalcase}}{{operationId}}{{/lambda.pascalcase}}: Response should be a valid {{dataType}} object")

    // Status Code Check
    Do $$$AssertEquals(httpResponse.StatusCode, {{code}}, "{{#lambda.pascalcase}}{{operationId}}{{/lambda.pascalcase}}: Should return {{code}}")
}
{{/responses}}
{{/vendorExtensions.x-crud-operation-create}}
{{/vendorExtensions.x-crud-operation}}
{{^vendorExtensions.x-crud-operation}}
/// Test for {{operationId}} (no custom spec property found, so renders only the scaffold)
Method Test{{#lambda.pascalcase}}{{operationId}}{{/lambda.pascalcase}}() {
    // TODO:
{{#allParams}}
    // Set p{{#lambda.pascalcase}}{{paramName}}{{/lambda.pascalcase}} = ""
{{/allParams}}
    // Set httpResponse = ..{{#lambda.pascalcase}}{{operationId}}{{/lambda.pascalcase}}(
{{#allParams}}
    // p{{#lambda.pascalcase}}{{paramName}}{{/lambda.pascalcase}}{{^-last}},{{/-last}}
{{/allParams}}
    // )
}
{{/vendorExtensions.x-crud-operation}}
{{/operation}}
{{/operations}}

/// Helper method to create parameter objects expected by SendRequest
Method MakeParamObject(pName As %String, pValue) As %RegisteredObject {
    Set paramObj = ##class(%RegisteredObject).%New()
    Set paramObj.Name = pName
    Set paramObj.Value = pValue
    Return paramObj
}

{{#operations}}
{{#operation}}
/// {{summary}}
/// OperationId: {{operationId}}
Method {{#lambda.pascalcase}}{{operationId}}{{/lambda.pascalcase}}({{#allParams}}p{{#lambda.pascalcase}}{{paramName}}{{/lambda.pascalcase}} As {{dataType}}{{^-last}},{{/-last}}{{/allParams}}) As %Net.HttpResponse {
    Set path = "{{path}}"
    Set queryParams = ""
    Set bodyStream = ""
    Set headers = ##class(%ListOfDataTypes).%New()
    Set formParams = ##class(%ListOfDataTypes).%New()
    Set multipartParams = ##class(%ListOfDataTypes).%New()

{{#pathParams}}
    Set path = $REPLACE(path, "{{=<% %>=}}{<%paramName%>}<%= {{ }} =%>", p{{#lambda.pascalcase}}{{paramName}}{{/lambda.pascalcase}})
{{/pathParams}}

{{#queryParams}}
    Set queryParams = queryParams _ "&" _ "{{name}}=" _ p{{#lambda.pascalcase}}{{paramName}}{{/lambda.pascalcase}}
    If $EXTRACT(queryParams) = "&" Set queryParams = "?" _ $EXTRACT(queryParams, 2, *)
{{/queryParams}}

{{#headerParams}}
    // Header parameter: {{name}}
    Do headers.Insert(..MakeParamObject("{{name}}", p{{#lambda.pascalcase}}{{paramName}}{{/lambda.pascalcase}}))
{{/headerParams}}

{{#formParams}}
{{#isFile}}
    // Multipart/form-data file parameter: {{name}}
    Do multipartParams.Insert(..MakeParamObject("{{name}}", p{{#lambda.pascalcase}}{{paramName}}{{/lambda.pascalcase}}))
{{/isFile}}
{{^isFile}}
    // Form parameter (x-www-form-urlencoded): {{name}}
    Do formParams.Insert(..MakeParamObject("{{name}}", p{{#lambda.pascalcase}}{{paramName}}{{/lambda.pascalcase}}))
{{/isFile}}
{{/formParams}}

{{#bodyParam}}
    // Handle body
    $$$ThrowOnError(p{{#lambda.pascalcase}}{{paramName}}{{/lambda.pascalcase}}.%JSONExportToStream(.bodyStream))
{{/bodyParam}}

    Set request = ##class({{{x-musketeers-package-name}}}.utils.HttpUtils).%New()
    Set request.BasePath = ..#BasePath
    Set request.HttpRequest.Https = 1
    Set httpResponse = request.SendRequest("{{httpMethod}}", path, queryParams, bodyStream, headers, formParams, multipartParams)
    Return httpResponse
}
{{/operation}}
{{/operations}}
}

模板中的重要部分包括:

  • CRUD特定条件块{{#vendorExtensions.x-crud-operation}}:仅当OpenAPI规范将操作标记为CRUD感知时,此块才会激活。
  • CREATE特定条件块{{#vendorExtensions.x-crud-operation-create}}:仅针对CRUD的“创建”操作触发。
  • 无CRUD扩展时的回退块{{^vendorExtensions.x-crud-operation}}:生成仅包含框架的代码,避免测试逻辑。

这些块共同提供了高度灵活的生成逻辑,可根据每个操作进行定制。


遍历预期响应

OpenAPI Generator的一个强大功能是能够使用Mustache循环遍历整个OpenAPI规范的结构。例如,在模板的这一部分中:

{{#responses}}
/// Test for {{operationId}} with response code {{code}}
Method Test{{#lambda.pascalcase}}{{operationId}}{{/lambda.pascalcase}}ResponseCode{{code}}() {
    ...
}
{{/responses}}

模板会遍历为该操作定义的每个响应代码。对于createPerson,这意味着生成两个完整的测试方法:

  • TestCreatePersonResponseCode201()
  • TestCreatePersonResponseCode400()

生成的测试会根据响应代码正确实例化预期的响应类型(PersonError),导入JSON响应,断言类型,并检查HTTP状态码。

以下是使用自定义模板时IrisOASTestGen生成的结果:

Import dc.musketeers.irisOasTestGenDemo.personApi.tests.model

Class dc.musketeers.irisOasTestGenDemo.personApi.tests.api.TestsPersonApi Extends %UnitTest.TestCase {

/// The base URL of the API
Parameter BasePath = "http://localhost:52773/dc/musketeers/irisOasTestGenDemo/personApi";

/// Preparation method for the whole test
Method OnBeforeAllTests() As %Status {
    Return $$$OK
}

/// Cleanup method for the whole test
Method OnAfterAllTests() As %Status {
    Return $$$OK
}

/// Preparation method for each test
Method OnBeforeOneTests(testname As %String) As %Status {
    Return $$$OK
}

/// Cleanup method for each test
Method OnAfterOneTest() As %Status {
    Do ##Class(dc.musketeers.irisOasTestGenDemo.personApi.Person).%KillExtent()
    Return $$$OK
}

/// Test for createPerson with response code 201
Method TestCreatePersonResponseCode201() {
    Set resource = ##Class(dc.musketeers.irisOasTestGenDemo.personApi.tests.model.Person).%New()
    #; // Set here the resource properties to test the API
    #; Set resource.property = "some value"
    #; ...

    // Execute the operation
    Set httpResponse = ..CreatePerson(resource)

    // Parse the JSON Response
    Set responseString = httpResponse.Data.Read()
    Set response = ##Class(Person).%New()
    Set sc = response.%JSONImport(responseString)

    // Response Body Type Check
    Do $$$AssertStatusOK(sc, "CreatePerson: Response should be a valid Person object")

    // Status Code Check
    Do $$$AssertEquals(httpResponse.StatusCode, 201, "CreatePerson: Should return 201")
}

/// Test for createPerson with response code 400
Method TestCreatePersonResponseCode400() {
    Set resource = ##Class(dc.musketeers.irisOasTestGenDemo.personApi.tests.model.Person).%New()
    #; // Set here the resource properties to test the API
    #; Set resource.property = "some value"
    #; ...

    // Execute the operation
    Set httpResponse = ..CreatePerson(resource)

    // Parse the JSON Response
    Set responseString = httpResponse.Data.Read()
    Set response = ##Class(Error).%New()
    Set sc = response.%JSONImport(responseString)

    // Response Body Type Check
    Do $$$AssertStatusOK(sc, "CreatePerson: Response should be a valid Error object")

    // Status Code Check
    Do $$$AssertEquals(httpResponse.StatusCode, 400, "CreatePerson: Should return 400")
}

/// Test for deletePerson (no custom spec property found, so renders only the scaffold)
Method TestDeletePerson() {
    // TODO:
    // Set pId = ""
    // Set httpResponse = ..DeletePerson(
    // pId
    // )
}
...
}

注意,对于没有CRUD元数据的操作,会生成第三个块——TestDeletePerson(),仅生成框架。这清楚地展示了条件Mustache块的效果。


使用自定义模板运行IrisOASTestGen

要使用自定义模板目录生成测试代码,请使用以下ObjectScript代码:

Set openapiFile = "/home/irisowner/dev/assets/person-api.json"
Set outputDir = "/home/irisowner/dev/tests"
Set packageName = "dc.musketeers.irisOasTestGenDemo.personApi.tests"
Set template = "/home/irisowner/dev/assets/mustache"

##class(dc.musketeers.irisOastestGen.Main).BuildAndDeploy(openapiFile, outputDir, packageName, template)

此命令与默认生成的工作方式完全相同,只是现在提供了自定义模板目录。


自动化流程

为了简化模板开发,可以使用一个小型shell脚本:

# 从项目存储库根目录运行:
cd java
sh build-demo-person-api.sh

此脚本:

  1. 使用自定义模板调用OpenAPI Generator
  2. 构建一个新的JAR
  3. 替换现有的JAR
  4. 执行测试代码生成

这大大加快了实验和模板迭代的速度。


调试Mustache模板并检查模型

自定义OpenAPI Generator模板时的一个挑战是理解模板模型的结构——提供给Mustache的JSON结构。此模型的文档有限,但IrisOASTestGen支持模型调试。使用以下代码片段启用调试输出:

Set openapiFile = "/home/irisowner/dev/assets/person-api.json"
Set outputDir = "/home/irisowner/dev/tests"
Set packageName = "dc.musketeers.irisOasTestGenDemo.personApi.tests"
Set template = "/home/irisowner/dev/assets/mustache"
Set debug = 1

##class(dc.musketeers.irisOastestGen.Main).BuildAndDeploy(openapiFile, outputDir, packageName, template, debug)

此模式会打印整个模型结构,允许探索字段,如:

  • 所有参数
  • 操作
  • 响应
  • 供应商扩展
  • 类型、导入和文件名

有关调试Mustache模板的更多信息,请参阅:https://openapi-generator.tech/docs/debugging/#templates


结论

在这两部分的系列文章中,我们探讨了如何扩展和调整IrisOASTestGen以支持面向CRUD的API。第一篇文章介绍了通过自定义扩展丰富OpenAPI文档的想法,使得以结构化的方式更容易表达常见的CRUD语义。本文展示了这些扩展如何使用Mustache模板驱动代码生成,并通过一个涉及createPerson操作的简化示例进行了说明。

这两篇文章共同概述了一个实用的工作流程:在OpenAPI规范中定义有意义的CRUD提示,在生成过程中解释它们,并将它们纳入模板逻辑。依赖基于契约的工具的开发人员可能会发现这种方法在尝试从其API规范生成更一致的测试、演示或框架时很有用。

本文所示的示例故意保持最小化,但它们提供了一种可以扩展以支持更复杂操作和更广泛测试生成策略的模式。p