如何在C#的FsCheck中为生成器/Arbitrary添加显式边界用例?
我实现了DateOnly.BeginningOfDay和DateOnly.EndOfDay两个扩展方法,用来把DateOnly对象转成DateTime。用FsCheck写测试时,因为FsCheck没有内置DateOnly和TimeOnly的生成器,所以自己实现了对应的生成器。但CI流水线跑完发现DateOnly.BeginningOfDay的测试没覆盖到TimeOnly.MinValue(即凌晨12:00:00)这个边界场景。
后来我用Gen.OneOf把常量生成器和原TimeOnly生成器组合,让边界用例更频繁执行,但这样边界用例会重复跑,浪费时间资源。我知道FsCheck会对默认值做特殊边界检查,想知道有没有办法给某个类型定义一组边界用例,让测试每次运行时自动执行这些用例且仅执行一次,而不是作为Gen.OneOf的选项重复执行。
代码实现
DateOnlyExtensions.cs
public static class DateOnlyExtensions { public static DateTime BeginningOfDay(this DateOnly date) => date.ToDateTime(TimeOnly.MinValue); public static DateTime EndOfDay(this DateOnly date) => date.ToDateTime(TimeOnly.MaxValue); }
DateOnlyExtensionTests.cs
说明
把参数合并成单个参数生成器,只是因为项目无法识别Prop.ForAll(OneArb, TwoArb, RedArb, BlueArb, (one, two, red, blue) => ...)这类写法。
添加显式边界用例前的代码
public class DateOnlyExtensionTests { static Gen<int> MakeIntInRangeGen(int start, int end) => from @int in Arb.Generate<int>() where @int >= start && @int < end select @int; static Gen<DateOnly> DateOnlyGen => from dateTime in Arb.Generate<DateTime>() select DateOnly.FromDateTime(dateTime); static Gen<TimeOnly> TimeOnlyGen => from hour in MakeIntInRangeGen(0, 24) from minute in MakeIntInRangeGen(0, 60) from second in MakeIntInRangeGen(0, 60) from millisecond in MakeIntInRangeGen(0, 1000) select new TimeOnly(hour, minute, second, millisecond); public class BeginningOfDay { [Property] public Property ShouldReturnTheEarliestTimeInTheDay() { var argsGen = from time in TimeOnlyGen from date in DateOnlyGen select new { date, time }; return Prop.ForAll( argsGen.ToArbitrary(), args => args.date.BeginningOfDay() <= args.date.ToDateTime(args.time)); } } public class EndOfDay { [Property] public Property ShouldReturnTheLatestTimeInTheDay() { var argsGen = from time in TimeOnlyGen from date in DateOnlyGen select new { date, time }; return Prop.ForAll( argsGen.ToArbitrary(), args => args.date.EndOfDay() >= args.date.ToDateTime(args.time)); } } }
添加显式边界用例后的代码
using System; using Core.Dates; using FluentAssertions; using FsCheck; using FsCheck.Xunit; using TestUtilities; using static Core.Math.MathHelpers; namespace Core.Tests.Dates { public class DateOnlyExtensionTests { static Gen<int> MakeIntInRangeGen(int start, int end) => from @int in Arb.Generate<int>() where @int >= start && @int < end select @int; static Gen<DateOnly> DateOnlyGen => from dateTime in Arb.Generate<DateTime>() select DateOnly.FromDateTime(dateTime); static Gen<TimeOnly> TimeOnlyGen => Gen.OneOf(TimeOnlyEdgeCaseGen, TimeOnlyRandomGen); static Gen<TimeOnly> TimeOnlyEdgeCaseGen => Gen.OneOf( Gen.Constant(TimeOnly.MinValue), Gen.Constant(TimeOnly.MaxValue)); static Gen<TimeOnly> TimeOnlyRandomGen => from hour in MakeIntInRangeGen(0, 24) from minute in MakeIntInRangeGen(0, 60) from second in MakeIntInRangeGen(0, 60) from millisecond in MakeIntInRangeGen(0, 1000) select new TimeOnly(hour, minute, second, millisecond); public class BeginningOfDay { [Property] public Property ShouldReturnTheEarliestTimeInTheDay() { var argsGen = from time in TimeOnlyGen from date in DateOnlyGen select new { date, time }; return Prop.ForAll( argsGen.ToArbitrary(), args => args.date.BeginningOfDay() <= args.date.ToDateTime(args.time)); } } public class EndOfDay { [Property] public Property ShouldReturnTheLatestTimeInTheDay() { var argsGen = from time in TimeOnlyGen from date in DateOnlyGen select new { date, time }; return Prop.ForAll( argsGen.ToArbitrary(), args => args.date.EndOfDay() >= args.date.ToDateTime(args.time)); } } } }
解决方案
1. 单独编写边界用例的单元测试
最简单直接的方式,给每个边界场景写独立的单元测试,确保这些用例每次运行都执行且仅执行一次。比如针对TimeOnly.MinValue和TimeOnly.MaxValue:
public class BeginningOfDay { // 属性测试:随机用例 [Property] public Property ShouldReturnTheEarliestTimeInTheDay_WithRandomTimes() { var argsGen = from time in TimeOnlyRandomGen from date in DateOnlyGen select new { date, time }; return Prop.ForAll( argsGen.ToArbitrary(), args => args.date.BeginningOfDay() <= args.date.ToDateTime(args.time)); } // 单元测试:边界用例,仅执行一次 [Fact] public void ShouldReturnTheEarliestTimeInTheDay_WithMinTime() { var date = DateOnly.FromDateTime(DateTime.Today); var result = date.BeginningOfDay(); result.Should().Be(date.ToDateTime(TimeOnly.MinValue)); } } public class EndOfDay { [Property] public Property ShouldReturnTheLatestTimeInTheDay_WithRandomTimes() { var argsGen = from time in TimeOnlyRandomGen from date in DateOnlyGen select new { date, time }; return Prop.ForAll( argsGen.ToArbitrary(), args => args.date.EndOfDay() >= args.date.ToDateTime(args.time)); } [Fact] public void ShouldReturnTheLatestTimeInTheDay_WithMaxTime() { var date = DateOnly.FromDateTime(DateTime.Today); var result = date.EndOfDay(); result.Should().Be(date.ToDateTime(TimeOnly.MaxValue)); } }
这种方式把边界测试和随机属性测试分开,边界用例每次必跑一次,随机用例按FsCheck默认次数执行,既保证覆盖边界,又不会重复执行浪费资源。
2. 自定义Arbitrary并结合Prop.Conj组合属性
如果想把边界用例和随机用例整合到同一个属性测试中,可以用Prop.Conj把边界用例的断言和随机用例的断言组合起来,确保边界用例只执行一次:
public class BeginningOfDay { [Property] public Property ShouldReturnTheEarliestTimeInTheDay() { // 边界用例:固定执行一次 var edgeCaseProp = Prop.ForAll( Arb.From(Gen.Constant((DateOnlyGen.Sample(0,1).First(), TimeOnly.MinValue))), args => args.Item1.BeginningOfDay() <= args.Item1.ToDateTime(args.Item2)); // 随机用例:按默认次数执行 var randomProp = Prop.ForAll( (from time in TimeOnlyRandomGen from date in DateOnlyGen select (date, time)).ToArbitrary(), args => args.date.BeginningOfDay() <= args.date.ToDateTime(args.time)); // 组合两个属性,同时执行 return edgeCaseProp.Conj(randomProp); } }
这里通过Gen.Constant生成单个边界用例,并用Prop.ForAll绑定,再和随机属性组合,这样边界用例会被执行一次,随机用例按FsCheck的默认配置执行。
内容的提问来源于stack exchange,提问作者Tyler Kasper

