Specflow table 搭配 json 資料的幾個需要注意的寫法

Specflow 的 table 預設用來處理單一欄位,若是想要在欄位裡面塞入 json 或者是比對 json 就得自行處理,這裡列出我常用的方式。

開發環境

  • Windows 11
  • .NET 8
  • Rider 2023.3.2
  • SpecFlow.xUnit 3.9.74
  • xunit 2.4.2

Specflow Table 轉成物件

錯誤情境描述

Feature 的描述如下

Scenario: 建立一筆會員(錯誤)
    Given 已準備 Member 資料(錯誤)
        | Id | Age | IpData                        | Orders         | State  |
        | 1  | 18  | ["192.168.0.1","192.168.0.2"] | [{"Id":"123"}] | Active |

 

Step 如下

[Given(@"已準備 Member 資料\(錯誤\)")]
public void Given已準備Member資料錯誤(Table table)
{
    var members = table.CreateSet(row =>
    {
        var member = new Member
        {
        };

        return member;
    });
}

 

Member 定義如下

namespace Lab.SpecflowTestProject;

public class Member
{
    public string Id { get; set; }

    public int Age { get; set; }

    public Name Name { get; set; }

    public State State { get; set; }

    public List<string> IpData { get; set; }

    public List<Order> Orders { get; set; }
}

public enum State
{
    None,
    Active,
    Inactive,
}

public class Order
{
    public string Id { get; set; }
}

public class Name
{
    public string FirstName { get; set; }

    public string LastName { get; set; }
}

table.CreateSet 解出的 IpData 集合物件是
["192.168.0.1"
"129.168.0.2"]


table.CreateSet 解出的 Orders 集合物件是
null
 

兩個都是錯誤的

($1 members = Count 
v [o] = Member 
Id = {string) "1" View 
v IpData = 
Count = 2 
[0] = {string) " ["192.168.0.1 View 
= {string) ""192.168.0.2"]" View 
> Raw View 
Name = {string) "yao" View 
v Orders = {Li• 
Count 
[O] = {Order) null 
> Raw View 
> Raw View

解法

最簡單的解法就是自行用 table.Rows 處理

[Given(@"已準備 Member 資料\(正確\)")]
public void Given已準備Member資料正確(Table table)
{
    var members = new List<Member>();
    foreach (var row in table.Rows)
    {
        var member = new Member();
        foreach (var header in table.Header)
        {
            switch (header)
            {
                case nameof(Member.Id):
                    member.Id = row[header];
                    break;
                case nameof(Member.Age):
                    member.Age = int.Parse(row[header]);
                    break;
                case nameof(Member.State):
                    member.State = Enum.Parse<State>(row[header]);
                    break;
                case nameof(Member.Name):
                    member.Name = TryGetName(row);
                    break;
                case nameof(Member.IpData):
                    member.IpData = TryGetIpData(row);
                    break;
                case nameof(Member.Orders):
                    member.Orders = TryGetOrders(row);
                    break;
            }
        }

        members.Add(member);
    }
}
private static Name? TryGetName(TableRow row)
{
    var data = row.TryGetValue(nameof(Member.Name), out var name)
        ? JsonSerializer.Deserialize<Name>(name)
        : null;
    return data;
}

private static List<Order>? TryGetOrders(TableRow row)
{
    var data = row.TryGetValue(nameof(Member.Orders), out var orders)
        ? JsonSerializer.Deserialize<List<Order>>(orders)
        : new List<Order>();
    return data;
}

private static List<string>? TryGetIpData(TableRow row)
{
    var data = row.TryGetValue(nameof(Member.IpData), out var ip)
        ? JsonSerializer.Deserialize<List<string>>(ip)
        : new List<string>();
    return data;
}

 

這樣一來資料就正確地被轉成正確的物件了,如下圖:

擴充方法

把上面的方法改成可以支援泛型的擴充方法

public static class TableExtensions
{
    public static IEnumerable<T>? CreateJsonSet<T>(this Table table)
    {
        var results = new List<T>();
        var type = typeof(T);
        foreach (var row in table.Rows)
        {
            var instance = Activator.CreateInstance<T>();
            foreach (var header in table.Header)
            {
                var property = type.GetProperty(header);
                if (property == null)
                {
                    continue;
                }

                var value = row[header];
                if (string.IsNullOrWhiteSpace(value))
                {
                    continue;
                }

                var propertyType = property.PropertyType;

                //若是泛型且是集合
                if (propertyType.IsGenericType
                    && propertyType.GetGenericTypeDefinition() == typeof(List<>))
                {
                    var listType = propertyType.GetGenericArguments()[0];
                    var list = JsonSerializer.Deserialize(value, typeof(List<>).MakeGenericType(listType));
                    property.SetValue(instance, list);
                }

                //若是物件且不是字串
                else if (propertyType.IsClass
                         && propertyType != typeof(string))
                {
                    var obj = JsonSerializer.Deserialize(value, propertyType);
                    property.SetValue(instance, obj);
                }

                //若是列舉
                else if (propertyType.IsEnum)
                {
                    property.SetValue(instance, Enum.Parse(propertyType, value));
                }
                else
                {
                    property.SetValue(instance, Convert.ChangeType(value, propertyType));
                }
            }

            results.Add(instance);
        }

        return results;
    }
}

 

執行結果如下圖:

Specflow 比對

錯誤情境描述

Specflow 預設也是只支援單一欄位,內建的 table.ComareToSet 是不支援 json 比對的

Scenario: 建立一筆會員(錯誤)
    Then 預期得到 Member 資料(錯誤)
        | Id | Age | IpData                        | Orders         | State  |
        | 1  | 18  | ["192.168.0.1","192.168.0.2"] | [{"Id":"123"}] | Active |

 

這樣寫,會得到比對失敗

[Then(@"預期得到 Member 資料\(錯誤\)")]
public void Then預期得到Member資料錯誤(Table table)
{
    var actual = CreateActualMembers();
    table.CompareToSet(actual);
}

 

解法

把 table 的內容轉成強型別 Excepted,手動建立 Actual,通過 FluentAssertions 進行兩者比對

[Then(@"預期得到 Member 資料\(正確\)")]
public void Then預期得到Member資料正確(Table table)
{
    var actual = CreateActualMembers();
    var expected = table.CreateJsonSet<Member>();

    actual.Should().BeEquivalentTo(expected, options => options
        .Including(x => x.Id)
        .Including(x => x.Age)
        .Including(x => x.Name)
        .Including(x => x.State)
        .Including(x => x.IpData)
        .Including(x => x.Orders)
    );
}

 

一個一個列出要比對的欄位有點辛苦,搭配 table.Header 指定要比對的欄位

[Then(@"預期得到 Member 資料\(正確\)")]
public void Then預期得到Member資料正確(Table table)
{
    var actual = CreateActualMembers();
    var expected = table.CreateJsonSet<Member>();
    var header = table.Header.ToHashSet();

    actual.Should().BeEquivalentTo(expected, options =>
    {
        options.Including(info => header.Contains(info.Name));
        if (header.Contains(nameof(Member.Name)))
        {
            options.Including(info => info.Name);
        }

        return options;
    });
}

 

經實驗,options.Including(info => header.Contains(info.Name)) 無法處理複雜型別,還需要加上 options.Including(info => info.Name)

if (header.Contains(nameof(Member.Name)))
{
  options.Including(info => info.Name);
}

 

驗證一下,是不是可以比對出錯誤

Scenario: 建立一筆會員(正確)
    Then 預期得到 Member 資料(正確)
        | Id | Age | Name                                      | IpData                        | Orders         | State  |
        | 1  | 18  | {"FirstName":"yaochang","LastName":"yu1"} | ["192.168.0.1","192.168.0.3"] | [{"Id":"124"}] | Active |

 

如我所預期,上述兩種方法都比對失敗

Xunit.Sdk.XunitException
Expected property actual[0].Name.LastName to be "yu1" with a length of 3, but "yu" has a length of 2, differs near "u" (index 1).
Expected actual[0].IpData[1] to be "192.168.0.3", but "192.168.0.2" differs near "2" (index 10).
Expected property actual[0].Orders[0].Id to be "124", but "123" differs near "3" (index 2).
Expected property actual[0].Name.LastName to be "yu1" with a length of 3, but "yu" has a length of 2, differs near "u" (index 1).

 

範例位置

sample.dotblog/Test/Specflow3/Lab.SpecflowTips/Lab.SpecflowCreateAndCompareJson at c3819796c98b9b8cafb527813f638ab9d757fa35 · yaochangyu/sample.dotblog (github.com)

 

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


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

Image result for microsoft+mvp+logo