利用 JsonPath 查詢語法比對物件屬性

JSON Path 是一種 JSON 文件查詢語言,其靈感來自 XPath 為 XML 文件提供的功能。它最初由 Matt Goëssner 提出,現在已成為 IETF 規範:RFC 9535。我強大的同事提出用 JsonPath 來比對特定的欄位,測試步驟讀起來清晰,復用性也高,實作起來也是蠻簡單的,接下來就看看我們怎麼做的。

開發環境

  • .NET 8
  • JsonPath.Net 1.1.2
  • SystemTextJson.JsonDiffPatch.Xunit 2.0.0

 

JsonPath的使用方法

參考

JSONPath - XPath for JSON (goessner.net)

GitHub - atifaziz/JSONPath: JSONPath (XPath-like syntax for JSON) C# implementation

範例

GitHub - atifaziz/JSONPath: JSONPath (XPath-like syntax for JSON) C# implementation

 

實作

在 Specflow 測試步驟裡的描述,可以使用 table 來比對多個欄位

Scenario: 用 Table 驗證資料
    When 調用端發送 "Get" 請求至 "api/member"
    Then 預期得到回傳 Member 結果為
        | Age | Birthday                    | 
        | 18  | 1/1/2000 12:00:00 AM +00:00 | 
    Then 預期得到回傳 Member.FullName 結果為
        | FirstName | LastName |
        | John      | Doe      |

 

或是用 JsonDiff 比對 Json 物件

Scenario: 用 JsonDiff 驗證資料
    When 調用端發送 "Get" 請求至 "api/member"
    Then 預期回傳內容為
    """
    {
        "Id": 1,
        "Age": 18,
        "Birthday": "2000-01-01T00:00:00+00:00",
         "FullName": {
            "FirstName": "John",
            "LastName": "Doe"
        }
    }
    """

 

模擬呼叫 API,回傳了一個 Member 物件

[When(@"調用端發送 ""(.*)"" 請求至 ""(.*)""")]
public void When調用端發送請求至(string httpMethod, string url)
{
    var data = new Member
    {
        Id = 1,
        Age = 18,
        Birthday = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero),
        FullName = new FullName
        {
            FirstName = "John",
            LastName = "Doe"
        }
    };
    this.ScenarioContext.Set(data);
}

 

當想要特別強調某個欄位的狀態時,必須針對不同的型別寫不同的步驟,實作雖然簡單但是步驟卻無法重複使用

Then 預期得到回傳 Member.FullName.FirstName 結果為 John

 

當用了JsonPath 查詢語法後,實作起來就變得簡單多了,要比對的是強型別物件只要先序列化即可

Scenario: 用 JsonPath 驗證資料
    When 調用端發送 "Get" 請求至 "api/member"
    Then 預期回傳內容中路徑 "$.Age" 的數值等於 "18"
    Then 預期回傳內容中路徑 "$.Birthday" 的時間等於 "2000-01-01T00:00:00+00:00"
    Then 預期回傳內容中路徑 "$.FullName.FirstName" 的字串等於 "John"
    Then 預期回傳內容中路徑 "$.FullName.LastName" 的字串等於 "Doe"

 

注意:JsonPath 雖然好用但也要注意案例的可讀性,例如不要使用 Regex Expression 正則表達式,這個只要遵守團隊的規範即可,像我們講好只能使用欄位表達式,$.FullName.FirstName、$.Members[0].FullName.FirstName

[Then(@"預期回傳內容中路徑 ""(.*)"" 的(" + OperationTypes + @") ""(.*)""")]
public void Then預期回傳內容中路徑的字串等於(string selector, string operationType, string expected)
{
    var data = this.ScenarioContext.Get<Member>();
    var json = JsonSerializer.Serialize(data);
    ContentShouldBe(json, selector, operationType, expected);
}

OperationTypes 是 Specflow 正則表達式的用法

private const string StringEquals = "字串等於";
private const string NumberEquals = "數值等於";
private const string BoolEquals = "布林值等於";
private const string JsonEquals = "Json等於";
private const string DateTimeEquals = "時間等於";

private const string OperationTypes = StringEquals
                                      + "|" + NumberEquals
                                      + "|" + BoolEquals
                                      + "|" + JsonEquals
                                      + "|" + DateTimeEquals;

 

private static void ContentShouldBe(string content, string selectPath, string operationType, string expected)
{
    var srcInstance = JsonNode.Parse(content);
    var jsonPath = JsonPath.Parse(selectPath);
    switch (operationType)
    {
        case StringEquals:
        {
            var actual = jsonPath.Evaluate(srcInstance).Matches.FirstOrDefault()?.Value?.GetValue<string>();
            var errorReason =
                $"{nameof(operationType)}: [{operationType}], {nameof(selectPath)}: [{selectPath}], {nameof(expected)}: [{expected}], {nameof(actual)}: [{actual}]";
            (actual ?? string.Empty).Should().Be(expected, errorReason);
            break;
        }
        case NumberEquals:
        {
            var actual = jsonPath.Evaluate(srcInstance).Matches.FirstOrDefault()?.Value?.GetValue<int>();
            var errorReason =
                $"{nameof(operationType)}: [{operationType}], {nameof(selectPath)}: [{selectPath}], {nameof(expected)}: [{expected}], {nameof(actual)}: [{actual}]";
            actual.Should().Be(int.Parse(expected), errorReason);
            break;
        }
        case BoolEquals:
        {
            var actual = jsonPath.Evaluate(srcInstance).Matches.FirstOrDefault()?.Value?.GetValue<bool>();
            var errorReason =
                $"{nameof(operationType)}: [{operationType}], {nameof(selectPath)}: [{selectPath}], {nameof(expected)}: [{expected}], {nameof(actual)}: [{actual}]";
            actual.Should().Be(bool.Parse(expected), errorReason);
            break;
        }
        case DateTimeEquals:
        {
            var expect = DateTimeOffset.Parse(expected);
            var actual = jsonPath.Evaluate(srcInstance).Matches.FirstOrDefault()
                    ?.Value
                    ?.GetValue<DateTimeOffset>()
                ;
            var errorReason =
                $"{nameof(operationType)}: [{operationType}], {nameof(selectPath)}: [{selectPath}], {nameof(expected)}: [{expect}], {nameof(actual)}: [{actual}]";
            actual.Should().Be(expect, errorReason);
            break;
        }
        case JsonEquals:
        {
            var actual = jsonPath.Evaluate(srcInstance).Matches.FirstOrDefault()?.Value;
            var expect = string.IsNullOrWhiteSpace(expected) ? null : JsonNode.Parse(expected);
            var diff = actual.Diff(expect);
            var errorReason =
                $"{nameof(operationType)}: [{operationType}], {nameof(selectPath)}: [{selectPath}], {nameof(expected)}: [{expected}], {nameof(actual)}: [{actual?.ToJsonString()}], diff: [{diff?.ToJsonString()}]";
            actual.DeepEquals(expect).Should().BeTrue(errorReason);
            break;
        }
    }
}

 

範例位置

sample.dotblog/Json/Lab.JsonPathForTestCase/Lab.JsonPathForTestCase.Test at e238d30d235e82646de10a3512f231da52061810 · yaochangyu/sample.dotblog · GitHub

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo