You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.05 09:50:15