Fixture dan Setup

Konteks bersama dan siklus hidup pengujian.

Daftar isi

xUnit membuat instance baru dari kelas uji untuk setiap metode pengujian, sehingga konteks yang dibangun di dalam konstruktor tidak terbawa ke pengujian berikutnya. Aturan siklus hidup ini menjamin isolasi antar kasus, tetapi menjadi boros apabila setiap pengujian memerlukan objek yang mahal dibangun. Topik ini membahas siklus hidup kelas uji, peran konstruktor sebagai setup dan metode Dispose sebagai teardown, serta fixture, yaitu konteks bersama yang dibuat sekali dan disuntikkan ke kelas uji yang memerlukannya.

Siklus hidup kelas uji#

Untuk setiap metode pengujian, xUnit membuat instance kelas uji yang baru, menjalankan konstruktornya, menjalankan metode pengujian itu, lalu memanggil Dispose bila kelas mengimplementasikan IDisposable. Pola ini menjadikan konstruktor tempat menyiapkan keadaan awal dan Dispose tempat membersihkannya, tanpa atribut tambahan apa pun. Akibatnya, field pada kelas uji tidak pernah dibagikan antar metode pengujian, karena setiap metode menerima instance yang masih bersih.

Kelas SiklusHidupTests pada berkas berikut membuktikan perilaku tersebut dengan dua pengujian yang saling menguji kebersihan konteks. Pengujian pertama menambahkan satu nilai ke daftar dan mengharapkan tiga elemen, sedangkan pengujian kedua mengharapkan daftar masih berisi dua elemen. Apabila instance dipakai ulang antar pengujian, salah satu keduanya pasti gagal karena daftar yang sama telah dimodifikasi.

Berkas konversi suhu berikut menjadi kelas yang diuji oleh fixture pada bagian selanjutnya. Pembangunan tabelnya disertai komentar sebagai operasi yang mahal, dan objek jadinya bersifat hanya-baca setelah dibangun.

csharp KonversiSuhu.cs
// Kelas yang diuji: tabel konversi suhu yang pembangunannya mahal.
public class TabelKonversiSuhu
{
    private readonly Dictionary<string, Func<double, double>> _tabel;

    public TabelKonversiSuhu()
    {
        // pembangunan tabel disimulasikan sebagai operasi yang mahal
        _tabel = new Dictionary<string, Func<double, double>>
        {
            ["C-ke-F"] = c => c * 9.0 / 5.0 + 32.0,
            ["F-ke-C"] = f => (f - 32.0) * 5.0 / 9.0,
            ["C-ke-K"] = c => c + 273.15,
        };
    }

    public double Konversi(string nama, double nilai)
    {
        if (!_tabel.TryGetValue(nama, out Func<double, double> fungsi))
        {
            throw new ArgumentException($"Konversi '{nama}' tidak dikenal.");
        }
        return fungsi(nilai);
    }
}
csharp UjiFixture.cs
using Xunit;

// Bagian 1: instance kelas uji baru untuk setiap metode pengujian.
public class SiklusHidupTests
{
    private readonly List<int> _riwayat;

    public SiklusHidupTests()
    {
        // konstruktor berjalan kembali untuk setiap metode pengujian
        _riwayat = new List<int> { 10, 20 };
    }

    [Fact]
    public void TambahNilai_MulaiDariDaftarBersih()
    {
        _riwayat.Add(30);
        Assert.Equal(3, _riwayat.Count);
    }

    [Fact]
    public void DaftarTetapBersih_PadaPengujianLain()
    {
        // bila instance dipakai ulang, daftar akan berisi tiga nilai
        // dari pengujian TambahNilai, dan assertion ini gagal
        Assert.Equal(2, _riwayat.Count);
    }
}

// Bagian 2: satu instance dibagi untuk seluruh kelas melalui class fixture.
public class TabelKonversiFixture : IDisposable
{
    public TabelKonversiSuhu Tabel { get; }

    public TabelKonversiFixture()
    {
        Tabel = new TabelKonversiSuhu();
    }

    public void Dispose()
    {
        // pembersihan sumber daya bersama dilakukan di sini
    }
}

public class KonversiSuhuTests : IClassFixture<TabelKonversiFixture>
{
    private readonly TabelKonversiFixture _fixture;

    public KonversiSuhuTests(TabelKonversiFixture fixture)
    {
        _fixture = fixture;
    }

    [Theory]
    [InlineData("C-ke-F", 100.0, 212.0)]
    [InlineData("F-ke-C", 212.0, 100.0)]
    [InlineData("C-ke-K", 0.0, 273.15)]
    public void Konversi_MenghasilkanNilaiYangDiharapkan(string nama, double masukan, double diharapkan)
    {
        double hasil = _fixture.Tabel.Konversi(nama, masukan);
        Assert.Equal(diharapkan, hasil, 2);
    }

    [Fact]
    public void Konversi_NamaTidakDikenal_MelemparArgumentException()
    {
        Assert.Throws<ArgumentException>(() => _fixture.Tabel.Konversi("X-ke-Y", 1.0));
    }
}

Class fixture untuk konteks yang mahal#

Class fixture membagikan satu instance ke seluruh metode pengujian dalam satu kelas. Caranya, kelas fixture menampung konteks yang mahal, kelas uji mengimplementasikan antarmuka IClassFixture<TFixture>, dan xUnit menyuntikkan instance tersebut melalui parameter konstruktor kelas uji. Instance fixture dibuat satu kali sebelum pengujian pertama kelas itu berjalan dan dibuang setelah pengujian terakhirnya selesai, sehingga Dispose pada fixture menjadi tempat teardown yang bersifat lintas pengujian.

Pada berkas di atas, TabelKonversiFixture membangun TabelKonversiSuhu satu kali, sedangkan KonversiSuhuTests menerimanya melalui konstruktornya dan memakai tabel yang sama pada empat kasus pengujian. Biaya pembangunan tabel pun dibayar sekali untuk satu kelas, bukan empat kali. Pola ini tepat untuk konteks yang mahal tetapi hanya dibaca oleh pengujian, seperti tabel konfigurasi, hasil parsing berkas data, atau koneksi sumber data baca-saja.

Kedua kelas uji dijalankan dengan proyek uji sementara yang dibuat dari templat yang sama dengan topik sebelumnya.

bash
# membuat proyek uji xUnit di dalam folder kerja
dotnet new xunit -o UjiFixture 2>&1 | grep "Restore succeeded"
text Keluaran
Restore succeeded.
bash
# menyalin kelas yang diuji dan berkas pengujian ke dalam proyek uji
# contoh bawaan templat dihapus karena sudah digantikan
cp KonversiSuhu.cs UjiFixture.cs UjiFixture/
rm UjiFixture/UnitTest1.cs

# menjalankan seluruh pengujian, hanya baris ringkasan yang ditampilkan
dotnet test UjiFixture 2>&1 | grep -E "Passed!|Failed!" | sed 's/, Duration.*//'
text Keluaran
Passed!  - Failed:     0, Passed:     6, Skipped:     0, Total:     6
bash
# menghapus proyek uji sementara setelah latihan selesai
rm -rf UjiFixture

Baris ringkasan memuat enam kasus, yaitu dua kasus dari SiklusHidupTests, tiga baris [InlineData] pada Konversi_MenghasilkanNilaiYangDiharapkan, dan satu kasus pelemparan exception. Kelima kasus pada KonversiSuhuTests memakai satu instance TabelKonversiFixture yang sama, yang dapat diverifikasi dengan menaruh titik henti (breakpoint) pada konstruktornya saat debugging.

Membagikan fixture antar kelas uji#

Apabila beberapa kelas uji memerlukan konteks yang sama, collection fixture membagikan satu instance lintas kelas. Kelas fixture tidak perlu diubah, tetapi sebuah kelas tanpa isi yang bertanda [CollectionDefinition] perlu dibuat untuk mendeklarasikan koleksinya, sedangkan setiap kelas uji yang ikut koleksi tersebut bertanda [Collection] dengan nama yang sama dan menerima fixture melalui konstruktornya seperti pada IClassFixture. Bentuk deklarasinya adalah sebagai berikut.

csharp Contoh: definisi koleksi untuk fixture lintas kelas
// tidak-dijalankan
[CollectionDefinition("TabelKonversi")]
public class TabelKonversiCollection : ICollectionFixture<TabelKonversiFixture>
{
}

[Collection("TabelKonversi")]
public class KonversiSuhuTestsLain
{
    private readonly TabelKonversiFixture _fixture;

    public KonversiSuhuTestsLain(TabelKonversiFixture fixture)
    {
        _fixture = fixture;
    }
}

Pemakaian fixture lintas kelas memiliki konsekuensi yang perlu diantisipasi. xUnit menjalankan kelas-kelas uji yang berbeda secara paralel secara default, sehingga satu instance yang sama bisa diakses oleh beberapa kelas pada saat yang bersamaan dan kelas fixture yang tidak aman terhadap thread menghasilkan kegagalan yang tidak menentu. Selain itu, pengujian yang memutasi fixture membuat hasil bergantung pada urutan eksekusi. Karena itu, fixture lintas kelas sebaiknya bersifat hanya-baca, dan keadaan yang perlu diubah sebaiknya tetap ditangani oleh konstruktor kelas uji yang instance-nya selalu baru.

Kesalahan umum#

Keadaan disimpan pada field kelas uji dengan harapan dibagikan ke pengujian lain. Harapan ini keliru karena setiap metode pengujian menerima instance baru, sehingga nilai yang ditulis satu pengujian tidak pernah terlihat oleh pengujian berikutnya dan kegagalannya sulit dilacak. Konteks yang benar-benar perlu dibagikan dipindahkan ke fixture melalui IClassFixture atau koleksinya.

Kelas fixture tidak memiliki konstruktor publik tanpa parameter. xUnit membuat instance fixture sendiri, sehingga konstruktor dengan parameter wajib membuat pembuatan instansnya gagal dan seluruh pengujian pada kelas tersebut dilaporkan error. Perbaikannya adalah menyediakan konstruktor tanpa parameter, dan kebutuhan data tambahan dibangun di dalam konstruktor fixture itu sendiri.

Penyiapan yang ditulis di dalam konstruktor kelas uji juga perlu diperiksa ulang setiap kali daftar kebutuhannya bertambah. Konstruktor yang menyiapkan banyak hal untuk semua pengujian membuat setiap kasus membayar biaya penyiapan yang tidak semuanya dipakai, dan pembaca uji kehilangan petunjuk tentang data mana yang relevan bagi kasusnya. Pecahan penyiapan menjadi beberapa metode pembantu, atau pindah total ke fixture bila memang dibagikan, menjaga setiap kasus tetap menyatakan kebutuhannya sendiri.

Ringkasan#

  • xUnit membuat instance kelas uji baru untuk setiap metode pengujian, sehingga konstruktor menjadi setup dan Dispose menjadi teardown yang berlaku per kasus.
  • IClassFixture<T> membagikan satu instance konteks ke seluruh pengujian dalam satu kelas melalui injeksi konstruktor.
  • [CollectionDefinition] dengan ICollectionFixture<T> membagikan satu instance lintas kelas uji, dan instance semacam itu sebaiknya hanya dibaca.
  • Fixture yang mahal dibangun tetapi hanya dibaca adalah pola yang aman, sedangkan fixture yang dimutasi membuat hasil bergantung pada urutan eksekusi.

Referensi#