Note: see the link below for the English version of this article.

https://duongnt.com/autofixture-create

AutoFixture tạo test data như thế nào?

Khi viết unit test cho code C#, tôi thường xuyên sử dụng thư viện AutoFixture để sinh dữ liệu test. Tuy nhiên, ta cần chú ý là dữ liệu này không thực sự là ngẫu nhiên. Trong bài hôm nay, chúng ta sẽ xem AutoFixture sinh dữ liệu với kiểu bool, enum, and int như thế nào khi ta sử dụng thiết lập mặc định.

Dữ liệu do AutoFixture tạo ra không thực sự ngẫu nhiên

Ta dùng AutoFixture để tạo 3 list với kiểu là bool, enum, và int.

public enum Season
{
    Spring,
    Summer,
    Fall,
    Winter
}

var fixture = new Fixture();
var bools = fixture.CreateMany<bool>(5)
var seasons = fixture.CreateMany<Season>(4);
var numbers = fixture.CreateMany<int>(255);

Kết quả như sau.

bools.ToList();
# true
# false
# true
# false
# true

seasons.ToList();
# Spring
# Summer
# Fall
# Winter

Dễ thấy là các biến trong list bools lặp xen kẽ giữa truefalse. Còn các giá trị trong seasons thì theo đúng thứ tự từ giá trị đầu tiên đến giá trị cuối cùng của enum Season.

Ta khó thấy quy luật trong list numbers hơn. Thoạt nhìn qua thì có vẻ các số trong list này được sinh ngẫu nhiên. Nhưng đoạn code sau đây có thể khiến bạn phải ngạc nhiên.

var numberSet = numbers.ToHashSet();
Console.WriteLine($"Tổng số: {numberSet.Count}. Lớn nhất: {numberSet.Max()}. Nhỏ nhất:{numberSet.Min()}");
# Tổng số: 255. Lớn nhất: 255. Nhỏ nhất: 1

Xác suất để 255 số integer sinh ngẫu nhiên đều rơi vào trong khoảng [1, 255] và không có số nào trùng lặp là vô cùng nhỏ.

Về bản chất, hàm CreateMany gọi hàm Create tương ứng với kiểu generic hiện tại trong một vòng lặp. Nếu ta gọi hàm Create nhiều lần thay vì gọi hàm CreateMany thì ta vẫn sẽ nhận được kết quả như trên.

Lớp BooleanSwitch

Với thiết lập mặc định, AutoFixture sẽ dùng hàm BooleanSwitch để sinh giá trị với kiểu bool. Đây là hàm Create bên trong lớp đó.

lock (this.syncRoot)
{
    this.b = !this.b;
    return this.b;
}

this.b đơn giản là một trường private.

private bool b;

Mỗi lần ta gọi hàm Create, lớp BooleanSwitch lấy giá trị phủ định tại thời điểm hiện tại của b và trả nó lại cho hàm gọi. Lớp này còn sử dụng lock để đảm bảo rằng kết quả của 2 lần gọi liên tiếp luôn xen kẽ true/false kể cả khi ta gọi hàm từ thread khác nhau.

Lớp EnumGenerator

Với kiểu enum, ta cần xem lớp EnumGenerator. Đây là hàm Create của nó. Sau khi thực hiện kiểm tra kiểu dữ liệu, nó gọi hàm private CreateValue.

var generator = this.EnsureGenerator(t);
generator.MoveNext();
return generator.Current;

Vậy hàm EnsureGenerator tạo generator bằng cách nào?

IEnumerator enumerator = null;
if (!this.enumerators.TryGetValue(t, out enumerator))
{
    enumerator = new RoundRobinEnumEnumerable(t).GetEnumerator();
    this.enumerators.Add(t, enumerator);
}
return enumerator;

Có thể thấy là EnsureGenerator khởi tạo enumerator với kiểu là RoundRobinEnumEnumerable. Khi enumerator đó đã hết phần tử, nó sẽ được khởi tạo lại. Phần code của RoundRobinEnumEnumerable là khá đơn giản.

this.values = Enum.GetValues(enumType).Cast<object>();

// ... lược bỏ

public IEnumerator GetEnumerator()
{
    while (true)
    {
        foreach (var obj in this.values)
        {
            yield return obj;
        }
    }
}

Ta có thể xác nhận là AutoFixture sẽ trả về lần lượt từng giá trị trong enum, từ cái đầu tiên tới cái cuối rồi lại vòng lại cái đầu tiên.

Lớp RandomNumericSequenceGenerator

Dùng hàm Create để sinh số integer

Với kiểu int, ta cần xem hàm Create này. Nếu các bạn thử dò theo hàm đó, các bạn sẽ thấy là nó gọi hàm GetNextRandom rồi chuyển giá trị trả về sang kiểu int.

GetNextRandom dùng System.Random để sinh số một cách ngẫu nhiên. Nhưng đó chưa phải toàn bộ câu chuyện. Hàm này còn dùng một HashSet với tên gọi là numbers để đảm bảo rằng nó không trả về giá trị trùng lặp.

do
{
    if (this.lower >= int.MinValue &&
        this.upper <= int.MaxValue)
    {
        result = this.random.Next((int)this.lower, (int)this.upper);
    }
    else
    {
        result = this.GetNextInt64InRange();
    }
}
while (this.numbers.Contains(result));

this.numbers.Add(result);

Nếu result chưa từng được sinh ra trước đó, GetNextRandom sẽ thêm nó vào HashSet trước khi trả về kết quả.

this.numbers.Add(result);
return result;

AutoFixture đặt cận trên và cận dưới như thế nào?

Các số được sinh ra phải nằm trong một khoảng định trước. Khoảng đó được thiết lập trong hàm EvaluateRange.

if (this.count == (this.upper - this.lower))
{
    this.count = 0;
    this.CreateRange();
}

this.count++;

Có thể thấy rằng AutoFixture sẽ trả về toàn bộ các số trong một khoảng trước khi thử chuyển sang khoảng mới. Ví du: nó sẽ trả về toàn bộ 255 số giữa [1, 255] trước khi sang khoảng mới. Và khi cần sang khoảng mới, nó sẽ gọi hàm CreateRange.

Mặc định là mảng limits sẽ chứa các giá trị [1, 255, 32767, 2147483647 ]. Khi mới khởi tạo, ta sẽ đi vào nhánh code dưới đây.

this.lower = this.limits[0];
this.upper = this.GetUpperRangeFromLimits();

private long GetUpperRangeFromLimits()
{
    return this.limits[1] >= int.MaxValue
            ? this.limits[1]
            : this.limits[1] + 1;
}

Có nghĩa là ban đầu lower == 1 and upper == 256. Vì cận trên của System.Random.Next là exclusive, lúc đầu AutoFixture chỉ sinh các số trong khoảng [1, 255], mỗi số sinh đúng 1 lần. Điều này khớp với kết quả của thử nghiệm ta thực hiện lúc trước.

Nếu ta sinh nhiều hơn 255 số integer thì sao?

Lúc này, ta sẽ đi vào nhánh code sau.

this.lower = this.upper;
this.upper = remaining.Min() + 1;

Bởi vì remaining == this.limits.Where(x => x > this.upper - 1).ToArray(), ta có kết quả như sau.

lower == 256
upper == 32768

Lần này, AutoFixture sẽ trả về từng số trong khoảng [256, 32767], mỗi số 1 lần.

Tương tự thế, sau đó AutoFixture sẽ trả về từng số trong khoảng [32768, 2147483647], mỗi số 1 lần.

Nếu AutoFixture đã trả về tất cả các số integer lớn hơn 0 thì sao?

Lúc này mảng remaining sẽ rỗng. Ta sẽ vào nhánh code dưới đây.

if (remaining.Any() && this.numbers.Any())
{
    this.lower = this.upper;
    this.upper = remaining.Min() + 1;
}

Rõ ràng là AutoFixture sẽ quay lại khoảng [1, 255]. Vì tất cả các giá trị trong HashSet đều bị xóa sạch tại cuối hàm CreateRange, ta không lo gặp phải giá trị trùng lặp.

this.numbers.Clear();

Tóm gọn

  • Đầu tiên, AutoFixture sinh tất cả các số trong khoảng [1, 255]. Mỗi số chỉ được trả về 1 lần.
  • Sau đó nó chuyển lên khoảng [256, 32767], rồi khoảng [32768, 2147483647]. Mỗi số vẫn chỉ được trả về 1 lần.
  • Rồi nó lại quay về khoảng [1, 255].

Giải thuật trên không phù hợp khi ta cần sinh rất nhiều số integer. Giả sử ta cần sinh 2,147,483,647 số. Khi ta đến số cuối cùng, ta sẽ phải gọi hàm System.Random.Next rất nhiều lần cho đến khi nhận được số cuối cùng ta chưa trả về. Mỗi lần gọi hàm, xác suất thành công của ta chỉ là 1/(2147483647 - 32768) == 4.65*10e-10.

Kết thúc

Có cách thiết lập AutoFixture để trả về giá trị ngẫu nhiên. Nhưng với thiết lập mặc định, dữ liệu trả về của một số kiểu là không ngẫu nhiên chút nào. Trong quá khứ, đã có lần tôi viết unit test không chuẩn vì không hiểu rõ đặc tính này.

A software developer from Vietnam and is currently living in Japan.

Leave a Reply