Note: see the link below for the English version of this article.
https://duongnt.com/autofixture-create
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 true
và false
. 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;
}
Và 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.