dotnet-testing-autodata-xunit-integration
npx skills add https://github.com/kevintsengtw/dotnet-testing-agent-skills --skill dotnet-testing-autodata-xunit-integration
Agent 安装分布
Skill 文档
AutoData 屬æ§å®¶æ:xUnit è AutoFixture çæ´åæç¨
觸ç¼ééµå
- AutoData
- InlineAutoData
- MemberAutoData
- CompositeAutoData
- xUnit AutoFixture æ´å
- 忏忏¬è©¦è³æ
- æ¸¬è©¦åæ¸æ³¨å ¥
- CollectionSizeAttribute
- å¤é¨æ¸¬è©¦è³æ
- CSV æ¸¬è©¦è³æ
- JSON æ¸¬è©¦è³æ
æ¦è¿°
AutoData 屬æ§å®¶ææ¯ AutoFixture.Xunit2 å¥ä»¶æä¾çåè½ï¼å° AutoFixture çè³æç¢çè½åè xUnit ç忏忏¬è©¦æ´åï¼è®æ¸¬è©¦åæ¸èªå注å
¥ï¼å¤§å¹
æ¸å°æ¸¬è©¦æºåç¨å¼ç¢¼ã
æ ¸å¿ç¹è²
- AutoDataï¼èªåç¢çæææ¸¬è©¦åæ¸
- InlineAutoDataï¼æ··ååºå®å¼èèªåç¢ç
- MemberAutoDataï¼çµåå¤é¨è³æä¾æº
- CompositeAutoDataï¼å¤éè³æä¾æºæ´å
- CollectionSizeAttributeï¼æ§å¶éåç¢çæ¸é
å®è£å¥ä»¶
<PackageReference Include="AutoFixture" Version="4.18.1" />
<PackageReference Include="AutoFixture.Xunit2" Version="4.18.1" />
dotnet add package AutoFixture.Xunit2
AutoDataï¼å®å ¨èªåç¢ç忏
AutoData æ¯æåºç¤ç屬æ§ï¼èªåçºæ¸¬è©¦æ¹æ³çææåæ¸ç¢çæ¸¬è©¦è³æã
åºæ¬ä½¿ç¨
using AutoFixture.Xunit2;
public class Person
{
public Guid Id { get; set; }
[StringLength(10)]
public string Name { get; set; } = string.Empty;
[Range(18, 80)]
public int Age { get; set; }
public string Email { get; set; } = string.Empty;
public DateTime CreateTime { get; set; }
}
[Theory]
[AutoData]
public void AutoData_æè½èªåç¢çææåæ¸(Person person, string message, int count)
{
// Arrange & Act - åæ¸å·²ç± AutoData èªåç¢ç
// Assert
person.Should().NotBeNull();
person.Id.Should().NotBe(Guid.Empty);
person.Name.Should().HaveLength(10); // éµå¾ª StringLength éå¶
person.Age.Should().BeInRange(18, 80); // éµå¾ª Range éå¶
message.Should().NotBeNullOrEmpty();
count.Should().NotBe(0);
}
éé DataAnnotation ç´æåæ¸
[Theory]
[AutoData]
public void AutoData_ééDataAnnotationç´æåæ¸(
[StringLength(5, MinimumLength = 3)] string shortName,
[Range(1, 100)] int percentage,
Person person)
{
// Assert
shortName.Length.Should().BeInRange(3, 5);
percentage.Should().BeInRange(1, 100);
person.Should().NotBeNull();
}
InlineAutoDataï¼æ··ååºå®å¼èèªåç¢ç
InlineAutoData çµåäº InlineData çåºå®å¼ç¹æ§è AutoData çèªåç¢çåè½ã
åºæ¬èªæ³
[Theory]
[InlineAutoData("VIP客æ¶", 1000)]
[InlineAutoData("ä¸è¬å®¢æ¶", 500)]
[InlineAutoData("æ°å®¢æ¶", 100)]
public void InlineAutoData_æ··ååºå®å¼èèªåç¢ç(
string customerType, // å°æç¬¬ 1 ååºå®å¼
decimal creditLimit, // å°æç¬¬ 2 ååºå®å¼
Person person) // ç± AutoFixture ç¢ç
{
// Arrange
var customer = new Customer
{
Person = person,
Type = customerType,
CreditLimit = creditLimit
};
// Assert
customer.Type.Should().Be(customerType);
customer.CreditLimit.Should().BeOneOf(1000, 500, 100);
customer.Person.Should().NotBeNull();
}
忏é åºä¸è´æ§
åºå®å¼çé åºå¿ é èæ¹æ³åæ¸é åºä¸è´ï¼
[Theory]
[InlineAutoData("ç¢åA", 100)] // ä¾åºå°æ name, price
[InlineAutoData("ç¢åB", 200)]
public void InlineAutoData_忏é åºä¸è´æ§(
string name, // å°æç¬¬ 1 ååºå®å¼
decimal price, // å°æç¬¬ 2 ååºå®å¼
string description, // ç± AutoFixture ç¢ç
Product product) // ç± AutoFixture ç¢ç
{
// Assert
name.Should().BeOneOf("ç¢åA", "ç¢åB");
price.Should().BeOneOf(100, 200);
description.Should().NotBeNullOrEmpty();
product.Should().NotBeNull();
}
â ï¸ éè¦éå¶ï¼åªè½ä½¿ç¨ç·¨è¯æå¸¸æ¸
// â
æ£ç¢ºï¼ä½¿ç¨å¸¸æ¸
[InlineAutoData("VIP", 100000)]
[InlineAutoData("Premium", 50000)]
// â é¯èª¤ï¼ä¸è½ä½¿ç¨è®æ¸
private const decimal VipCreditLimit = 100000m;
[InlineAutoData("VIP", VipCreditLimit)] // ç·¨è¯é¯èª¤
// â é¯èª¤ï¼ä¸è½ä½¿ç¨éç®å¼
[InlineAutoData("VIP", 100 * 1000)] // ç·¨è¯é¯èª¤
éè¦ä½¿ç¨è¤éè³ææï¼æä½¿ç¨ MemberAutoDataã
MemberAutoDataï¼çµåå¤é¨è³æä¾æº
MemberAutoData å
許å¾é¡å¥çæ¹æ³ãå±¬æ§ææ¬ä½ä¸ç²åæ¸¬è©¦è³æã
使ç¨éæ æ¹æ³
public class MemberAutoDataTests
{
public static IEnumerable<object[]> GetProductCategories()
{
yield return new object[] { "3Cç¢å", "TECH" };
yield return new object[] { "æé£¾é
ä»¶", "FASHION" };
yield return new object[] { "å±
å®¶çæ´»", "HOME" };
yield return new object[] { "éåå¥èº«", "SPORTS" };
}
[Theory]
[MemberAutoData(nameof(GetProductCategories))]
public void MemberAutoData_使ç¨éæ
æ¹æ³è³æ(
string categoryName, // ä¾èª GetProductCategories
string categoryCode, // ä¾èª GetProductCategories
Product product) // ç± AutoFixture ç¢ç
{
// Arrange
var categorizedProduct = new CategorizedProduct
{
Product = product,
CategoryName = categoryName,
CategoryCode = categoryCode
};
// Assert
categorizedProduct.CategoryName.Should().Be(categoryName);
categorizedProduct.CategoryCode.Should().Be(categoryCode);
categorizedProduct.Product.Should().NotBeNull();
}
}
使ç¨éæ 屬æ§
public static IEnumerable<object[]> StatusTransitions => new[]
{
new object[] { OrderStatus.Created, OrderStatus.Confirmed },
new object[] { OrderStatus.Confirmed, OrderStatus.Shipped },
new object[] { OrderStatus.Shipped, OrderStatus.Delivered },
new object[] { OrderStatus.Delivered, OrderStatus.Completed }
};
[Theory]
[MemberAutoData(nameof(StatusTransitions))]
public void MemberAutoData_使ç¨éæ
屬æ§_è¨å®çæ
è½æ(
OrderStatus fromStatus,
OrderStatus toStatus,
Order order)
{
// Arrange
order.Status = fromStatus;
// Act
order.TransitionTo(toStatus);
// Assert
order.Status.Should().Be(toStatus);
}
èªè¨ AutoData 屬æ§
建ç«å°å±¬ç AutoData é ç½®ï¼
DomainAutoDataAttribute
public class DomainAutoDataAttribute : AutoDataAttribute
{
public DomainAutoDataAttribute() : base(() => CreateFixture())
{
}
private static IFixture CreateFixture()
{
var fixture = new Fixture();
// è¨å® Person çèªè¨è¦å
fixture.Customize<Person>(composer => composer
.With(p => p.Age, () => Random.Shared.Next(18, 65))
.With(p => p.Email, () => $"user{Random.Shared.Next(1000)}@example.com")
.With(p => p.Name, () => $"æ¸¬è©¦ç¨æ¶{Random.Shared.Next(100)}"));
// è¨å® Product çèªè¨è¦å
fixture.Customize<Product>(composer => composer
.With(p => p.Price, () => Random.Shared.Next(100, 10000))
.With(p => p.IsAvailable, true)
.With(p => p.Name, () => $"ç¢å{Random.Shared.Next(1000)}"));
return fixture;
}
}
BusinessAutoDataAttribute
public class BusinessAutoDataAttribute : AutoDataAttribute
{
public BusinessAutoDataAttribute() : base(() => CreateFixture())
{
}
private static IFixture CreateFixture()
{
var fixture = new Fixture();
// è¨å® Order çæ¥åè¦å
fixture.Customize<Order>(composer => composer
.With(o => o.Status, OrderStatus.Created)
.With(o => o.Amount, () => Random.Shared.Next(1000, 50000))
.With(o => o.OrderNumber, () => $"ORD{DateTime.Now:yyyyMMdd}{Random.Shared.Next(1000):D4}"));
return fixture;
}
}
使ç¨èªè¨ AutoData
[Theory]
[DomainAutoData]
public void 使ç¨DomainAutoData(Person person, Product product)
{
person.Age.Should().BeInRange(18, 64);
person.Email.Should().EndWith("@example.com");
product.IsAvailable.Should().BeTrue();
}
[Theory]
[BusinessAutoData]
public void 使ç¨BusinessAutoData(Order order)
{
order.Status.Should().Be(OrderStatus.Created);
order.Amount.Should().BeInRange(1000, 49999);
order.OrderNumber.Should().StartWith("ORD");
}
CompositeAutoDataï¼å¤éè³æä¾æºæ´å
çµåå¤åèªè¨ AutoData é ç½®ï¼
public class CompositeAutoDataAttribute : AutoDataAttribute
{
public CompositeAutoDataAttribute(params Type[] autoDataAttributeTypes)
: base(() => CreateFixture(autoDataAttributeTypes))
{
}
private static IFixture CreateFixture(Type[] autoDataAttributeTypes)
{
var fixture = new Fixture();
foreach (var attributeType in autoDataAttributeTypes)
{
var createFixtureMethod = attributeType.GetMethod(
"CreateFixture",
BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.FlattenHierarchy);
if (createFixtureMethod != null)
{
var sourceFixture = (IFixture)createFixtureMethod.Invoke(null, null)!;
foreach (var customization in sourceFixture.Customizations)
{
fixture.Customizations.Add(customization);
}
}
}
return fixture;
}
}
// ä½¿ç¨æ¹å¼
[Theory]
[CompositeAutoData(typeof(DomainAutoDataAttribute), typeof(BusinessAutoDataAttribute))]
public void CompositeAutoData_æ´åå¤éè³æä¾æº(
Person person,
Product product,
Order order)
{
// DomainAutoData çè¨å®
person.Age.Should().BeInRange(18, 64);
product.IsAvailable.Should().BeTrue();
// BusinessAutoData çè¨å®
order.Status.Should().Be(OrderStatus.Created);
}
CollectionSizeAttributeï¼æ§å¶éå大å°
AutoData é è¨çéå大尿¯ 3ï¼å¯ééèªè¨å±¬æ§æ§å¶ï¼
CollectionSizeAttribute 實ä½
using AutoFixture;
using AutoFixture.Kernel;
using AutoFixture.Xunit2;
using System.Reflection;
public class CollectionSizeAttribute : CustomizeAttribute
{
private readonly int _size;
public CollectionSizeAttribute(int size)
{
_size = size;
}
public override ICustomization GetCustomization(ParameterInfo parameter)
{
ArgumentNullException.ThrowIfNull(parameter);
var objectType = parameter.ParameterType.GetGenericArguments()[0];
var isTypeCompatible = parameter.ParameterType.IsGenericType &&
parameter.ParameterType.GetGenericTypeDefinition()
.MakeGenericType(objectType)
.IsAssignableFrom(typeof(List<>).MakeGenericType(objectType));
if (!isTypeCompatible)
{
throw new InvalidOperationException(
$"{nameof(CollectionSizeAttribute)} æå®çåå¥è List ä¸ç¸å®¹: " +
$"{parameter.ParameterType} {parameter.Name}");
}
var customizationType = typeof(CollectionSizeCustomization<>).MakeGenericType(objectType);
return (ICustomization)Activator.CreateInstance(customizationType, parameter, _size)!;
}
private class CollectionSizeCustomization<T> : ICustomization
{
private readonly ParameterInfo _parameter;
private readonly int _repeatCount;
public CollectionSizeCustomization(ParameterInfo parameter, int repeatCount)
{
_parameter = parameter;
_repeatCount = repeatCount;
}
public void Customize(IFixture fixture)
{
fixture.Customizations.Add(
new FilteringSpecimenBuilder(
new FixedBuilder(fixture.CreateMany<T>(_repeatCount).ToList()),
new EqualRequestSpecification(_parameter)));
}
}
}
ä½¿ç¨ CollectionSizeAttribute
[Theory]
[AutoData]
public void CollectionSize_æ§å¶èªåç¢çéå大å°(
[CollectionSize(5)] List<Product> products,
[CollectionSize(3)] List<Order> orders,
Customer customer)
{
// Assert
products.Should().HaveCount(5);
orders.Should().HaveCount(3);
customer.Should().NotBeNull();
products.Should().AllSatisfy(product =>
{
product.Name.Should().NotBeNullOrEmpty();
product.Price.Should().BeGreaterOrEqualTo(0);
});
}
å¤é¨æ¸¬è©¦è³ææ´å
æ¸¬è©¦å°æ¡æªæ¡è¨å®
å¨ .csproj ä¸è¨å®å¤é¨è³ææªæ¡ï¼
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!-- CSV æªæ¡ -->
<Content Include="TestData\*.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- JSON æªæ¡ -->
<Content Include="TestData\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
</ItemGroup>
</Project>
CSV æªæ¡æ´å
TestData/products.csv
ProductId,Name,Category,Price,IsAvailable
1,"iPhone 15","3Cç¢å",35900,true
2,"MacBook Pro","3Cç¢å",89900,true
3,"AirPods Pro","3Cç¢å",7490,false
4,"Nike Air Max","éåç¨å",4200,true
CSV è®åèæ´å
using CsvHelper;
using CsvHelper.Configuration;
using System.Globalization;
public class ExternalDataIntegrationTests
{
public static IEnumerable<object[]> GetProductsFromCsv()
{
var csvPath = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "products.csv");
using var reader = new StreamReader(csvPath);
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
};
using var csv = new CsvReader(reader, config);
var records = csv.GetRecords<ProductCsvRecord>().ToList();
foreach (var record in records)
{
yield return new object[]
{
record.ProductId,
record.Name,
record.Category,
record.Price,
record.IsAvailable
};
}
}
[Theory]
[MemberAutoData(nameof(GetProductsFromCsv))]
public void CSVæ´å測試_ç¢åé©è(
int productId,
string productName,
string category,
decimal price,
bool isAvailable,
Customer customer,
Order order)
{
// Assert - CSV è³æ
productId.Should().BePositive();
productName.Should().NotBeNullOrEmpty();
category.Should().BeOneOf("3Cç¢å", "éåç¨å");
price.Should().BePositive();
// Assert - AutoFixture ç¢ççè³æ
customer.Should().NotBeNull();
order.Should().NotBeNull();
}
}
public class ProductCsvRecord
{
public int ProductId { get; set; }
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public decimal Price { get; set; }
public bool IsAvailable { get; set; }
}
JSON æªæ¡æ´å
TestData/customers.json
[
{
"customerId": 1,
"name": "å¼µä¸",
"email": "zhang@example.com",
"type": "VIP",
"creditLimit": 100000
},
{
"customerId": 2,
"name": "æå",
"email": "li@example.com",
"type": "Premium",
"creditLimit": 50000
}
]
JSON è®åèæ´å
using System.Text.Json;
public static IEnumerable<object[]> GetCustomersFromJson()
{
var jsonPath = Path.Combine(Directory.GetCurrentDirectory(), "TestData", "customers.json");
var jsonContent = File.ReadAllText(jsonPath);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var customers = JsonSerializer.Deserialize<List<CustomerJsonRecord>>(jsonContent, options)!;
foreach (var customer in customers)
{
yield return new object[]
{
customer.CustomerId,
customer.Name,
customer.Email,
customer.Type,
customer.CreditLimit
};
}
}
[Theory]
[MemberAutoData(nameof(GetCustomersFromJson))]
public void JSONæ´å測試_客æ¶é©è(
int customerId,
string name,
string email,
string customerType,
decimal creditLimit,
Order order)
{
// Assert - JSON è³æ
customerId.Should().BePositive();
name.Should().NotBeNullOrEmpty();
email.Should().Contain("@");
customerType.Should().BeOneOf("VIP", "Premium", "Regular");
creditLimit.Should().BePositive();
// Assert - AutoFixture ç¢ççè³æ
order.Should().NotBeNull();
}
è³æä¾æºè¨è¨æ¨¡å¼
é層å¼è³æçµç¹
namespace AutoData.Tests.DataSources;
/// <summary>
/// æ¸¬è©¦è³æä¾æºåºåºé¡å¥
/// </summary>
public abstract class BaseTestData
{
protected static string GetTestDataPath(string fileName)
{
return Path.Combine(Directory.GetCurrentDirectory(), "TestData", fileName);
}
}
/// <summary>
/// ç¢åæ¸¬è©¦è³æä¾æº
/// </summary>
public class ProductTestDataSource : BaseTestData
{
public static IEnumerable<object[]> BasicProducts()
{
yield return new object[] { "iPhone", 35900m, true };
yield return new object[] { "MacBook", 89900m, true };
yield return new object[] { "AirPods", 7490m, false };
}
public static IEnumerable<object[]> ElectronicsProducts()
{
// å¾ CSV æªæ¡è®å
var csvPath = GetTestDataPath("electronics.csv");
// ... è®åé輯
}
}
/// <summary>
/// å®¢æ¶æ¸¬è©¦è³æä¾æº
/// </summary>
public class CustomerTestDataSource : BaseTestData
{
public static IEnumerable<object[]> VipCustomers()
{
yield return new object[] { "å¼µä¸", "VIP", 100000m };
yield return new object[] { "æå", "VIP", 150000m };
}
}
å¯éç¨è³æé
/// <summary>
/// å¯éç¨çæ¸¬è©¦è³æé
/// </summary>
public static class ReusableTestDataSets
{
public static class ProductCategories
{
public static IEnumerable<object[]> All()
{
yield return new object[] { "3Cç¢å", "TECH" };
yield return new object[] { "æé£¾é
ä»¶", "FASHION" };
yield return new object[] { "å±
å®¶çæ´»", "HOME" };
}
public static IEnumerable<object[]> Electronics()
{
yield return new object[] { "ææ©", "MOBILE" };
yield return new object[] { "çé»", "LAPTOP" };
}
}
public static class CustomerTypes
{
public static IEnumerable<object[]> All()
{
yield return new object[] { "VIP", 100000m, 0.15m };
yield return new object[] { "Premium", 50000m, 0.10m };
yield return new object[] { "Regular", 20000m, 0.05m };
}
}
}
è Awesome Assertions åä½
[Theory]
[InlineAutoData("VIP", 100000)]
[InlineAutoData("Premium", 50000)]
[InlineAutoData("Regular", 20000)]
public void AutoDataèAwesomeAssertionsåä½_客æ¶çç´é©è(
string customerLevel,
decimal expectedCreditLimit,
[Range(1000, 15000)] decimal orderAmount,
Customer customer,
Order order)
{
// Arrange
customer.Type = customerLevel;
customer.CreditLimit = expectedCreditLimit;
order.Amount = orderAmount;
// Act
var canPlaceOrder = customer.CanPlaceOrder(order.Amount);
var discountRate = CalculateDiscount(customer.Type, order.Amount);
// Assert - ä½¿ç¨ Awesome Assertions èªæ³
customer.Type.Should().Be(customerLevel);
customer.CreditLimit.Should().Be(expectedCreditLimit);
customer.CreditLimit.Should().BePositive();
order.Amount.Should().BeInRange(1000m, 15000m);
canPlaceOrder.Should().BeTrue();
discountRate.Should().BeInRange(0m, 0.3m);
}
private static decimal CalculateDiscount(string customerType, decimal orderAmount)
{
var baseDiscount = customerType switch
{
"VIP" => 0.15m,
"Premium" => 0.10m,
"Regular" => 0.05m,
_ => 0m
};
var largeOrderBonus = orderAmount > 30000m ? 0.05m : 0m;
return Math.Min(baseDiscount + largeOrderBonus, 0.3m);
}
æä½³å¯¦è¸
æè©²å
-
åç¨ DataAnnotation
- å¨åæ¸ä¸ä½¿ç¨
[StringLength]ã[Range]ç´æè³æç¯å - 確ä¿ç¢ççè³æç¬¦åæ¥åè¦å
- å¨åæ¸ä¸ä½¿ç¨
-
建ç«å¯éç¨çèªè¨ AutoData
- çºä¸åé å建ç«å°å±¬ç AutoData 屬æ§
- éä¸ç®¡çæ¸¬è©¦è³æçç¢çè¦å
-
ä½¿ç¨ MemberAutoData èçè¤éè³æ
- ç¶ InlineAutoData ç¡æ³æ»¿è¶³éæ±æä½¿ç¨
- æ¯æ´è®æ¸ãéç®å¼åå¤é¨è³æä¾æº
-
çµç¹æ¸¬è©¦è³æä¾æº
- 建ç«é層å¼çè³æä¾æºçµæ§
- å°ç¸éè³æéä¸ç®¡ç
æè©²é¿å
-
å¨ InlineAutoData 使ç¨é常æ¸å¼
- InlineAutoData åªæ¥åç·¨è¯æå¸¸æ¸
- éè¦åæ 弿æ¹ç¨ MemberAutoData
-
é度è¤éç CompositeAutoData
- é¿å çµåéå¤ç AutoData 便º
- ä¿æé ç½®çå¯çè§£æ§
-
忽ç¥åæ¸é åº
- InlineAutoData çåºå®å¼å¿ é è忏é åºä¸è´
- é¯èª¤çé åºæå°è´åå¥ä¸ç¬¦
ç¨å¼ç¢¼ç¯æ¬
è«åè templates è³æå¤¾ä¸çç¯ä¾æªæ¡ï¼
- autodata-attributes.cs – AutoData 屬æ§å®¶æä½¿ç¨ç¯ä¾
- external-data-integration.cs – CSV/JSON å¤é¨è³ææ´å
- advanced-patterns.cs – é²é模å¼è CollectionSizeAttribute
èå ¶ä»æè½çéä¿
- autofixture-basicsï¼æ¬æè½çåç½®ç¥è
- autofixture-customizationï¼èªè¨åçç¥å¯ç¨æ¼ AutoData 屬æ§
- autofixture-nsubstitute-integrationï¼ä¸ä¸æ¥å¸ç¿ç®æ¨
- awesome-assertions-guideï¼æé ä½¿ç¨æåæ¸¬è©¦å¯è®æ§
åèè³æº
åå§æç«
æ¬æè½å §å®¹æç èªãèæ´¾è»é«å·¥ç¨å¸«ç測試修練 – 30 å¤©ææ°ãç³»åæç« ï¼
- Day 12 – çµå AutoDataï¼xUnit è AutoFixture çæ´åæç¨
- éµäººè³½æç« ï¼https://ithelp.ithome.com.tw/articles/10375296
- ç¯ä¾ç¨å¼ç¢¼ï¼https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day12