Unit testing adalah praktik menulis kode untuk menguji unit kode lain (biasanya satu method atau class) secara terisolasi, memastikan logika tersebut berperilaku sesuai ekspektasi tanpa perlu menjalankan seluruh aplikasi atau bergantung pada database sungguhan.

ASP.NET Core mendukung berbagai framework testing populer seperti xUnit, NUnit, dan MSTest, dengan xUnit menjadi pilihan paling umum di komunitas .NET modern karena sintaksnya yang bersih dan dukungan yang baik dari tooling Visual Studio maupun dotnet CLI.

Artikel ini membahas cara menulis unit test dasar dengan xUnit, memakai mocking untuk mengisolasi dependensi eksternal, serta prinsip Arrange-Act-Assert yang menjadi struktur standar penulisan test yang jelas dan mudah dibaca.

Membuat Project Test dan Test Pertama

Atribut Fact menandai method sebagai satu test case

dotnet new xunit -n TokoOnline.Tests dotnet add TokoOnline.Tests reference ../TokoOnline.Api public class KalkulatorTests { [Fact] public void Tambah_DuaAngkaPositif_MengembalikanJumlahBenar() { var kalkulator = new Kalkulator(); int hasil = kalkulator.Tambah(2, 3); Assert.Equal(5, hasil); } }
Atribut [Fact] menandai sebuah method sebagai satu test case tunggal yang akan dijalankan oleh test runner, dengan Assert.Equal memeriksa apakah hasil aktual sesuai dengan nilai yang diharapkan, dan test akan dianggap gagal jika nilai keduanya berbeda.

Arrange-Act-Assert sebagai Struktur Test

AAA memberi struktur yang jelas dan konsisten pada setiap test

[Fact] public void HitungDiskon_HargaDiatasSejuta_MendapatDiskon10Persen() { // Arrange: siapkan kondisi awal var produk = new Produk { Harga = 2000000 }; // Act: jalankan logika yang diuji decimal hargaFinal = produk.HitungHargaSetelahDiskon(); // Assert: periksa hasilnya sesuai ekspektasi Assert.Equal(1800000, hargaFinal); }
Pola Arrange-Act-Assert membagi setiap test menjadi tiga bagian jelas: menyiapkan data dan kondisi awal, menjalankan kode yang akan diuji, dan memeriksa apakah hasilnya sesuai ekspektasi, membuat setiap test mudah dipahami maksudnya meski hanya dibaca sekilas.

Theory untuk Menguji Beberapa Input Sekaligus

Theory menjalankan satu method test dengan beberapa set data berbeda

[Theory] [InlineData(2, 3, 5)] [InlineData(-1, 1, 0)] [InlineData(0, 0, 0)] public void Tambah_BerbagaiInput_MengembalikanHasilBenar(int a, int b, int expected) { var kalkulator = new Kalkulator(); Assert.Equal(expected, kalkulator.Tambah(a, b)); }
Atribut [Theory] dikombinasikan dengan [InlineData] memungkinkan satu method test dijalankan berulang kali dengan kombinasi data berbeda, menghindari duplikasi kode test yang hampir identik hanya karena perbedaan nilai input dan hasil yang diharapkan.

Mocking Dependensi dengan Moq

Mock menggantikan dependensi asli dengan perilaku yang dikontrol

var mockRepo = new Mock<IProdukRepository>(); mockRepo.Setup(r => r.GetByIdAsync(1)) .ReturnsAsync(new Produk { Id = 1, Nama = "Laptop" }); var service = new ProdukService(mockRepo.Object); var hasil = await service.GetNamaProduk(1); Assert.Equal("Laptop", hasil);
Library Moq memungkinkan pembuatan object palsu (mock) yang meniru perilaku dependensi asli seperti IProdukRepository, memungkinkan logika di ProdukService diuji secara terisolasi tanpa perlu database sungguhan, dengan hasil yang sepenuhnya bisa diprediksi dan dikontrol oleh test itu sendiri.

Mocking Dependency dengan Moq pada Unit Test Service

Unit test OrderService memakai Mock<IProductRepository>, tanpa database nyata

public class OrderServiceTests { [Fact] public async Task CreateOrder_StokTidakCukup_MelemparException() { // Arrange var repoMock = new Mock<IProductRepository>(); repoMock.Setup(r => r.GetByIdAsync(1)) .ReturnsAsync(new Product { Id = 1, Stock = 2 }); var service = new OrderService(repoMock.Object); // Act & Assert await Assert.ThrowsAsync<InvalidOperationException>( () => service.CreateOrderAsync(productId: 1, qty: 5)); repoMock.Verify(r => r.GetByIdAsync(1), Times.Once); // Test lulus tanpa menyentuh database sungguhan sama sekali } }

Unit test yang baik untuk business logic di ASP.NET Core seharusnya tidak bergantung pada database, API eksternal, atau file system yang sesungguhnya, karena dependency semacam itu membuat test lambat, rapuh, dan sulit dijalankan berulang kali di pipeline CI/CD. Library Moq memungkinkan pembuatan objek palsu (mock) dari sebuah interface seperti IProductRepository, lengkap dengan kontrol penuh atas nilai apa yang dikembalikan ketika method tertentu dipanggil.

Pola Arrange-Act-Assert pada contoh di atas memisahkan tiga tahap test secara jelas: Arrange menyiapkan mock dan data awal, Act menjalankan method yang diuji, dan Assert memverifikasi hasilnya. Method repoMock.Verify() di akhir memastikan GetByIdAsync benar-benar dipanggil tepat satu kali, berguna untuk memastikan service tidak melakukan query database berulang kali secara tidak perlu untuk operasi yang sama.

Agar sebuah class service benar-benar bisa diuji dengan mock seperti ini, dependency-nya harus didaftarkan lewat interface dan disuntikkan lewat constructor, bukan dibuat langsung di dalam method (misalnya new ProductRepository() di dalam OrderService). Prinsip ini sejalan dengan Dependency Injection yang sudah dibahas sebelumnya, sekaligus menjadi alasan utama mengapa desain yang loosely coupled jauh lebih mudah diuji dibandingkan kode yang dependency-nya ditulis secara hardcoded.

Integration Testing dengan WebApplicationFactory

WebApplicationFactory menjalankan seluruh pipeline aplikasi di dalam proses test

public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public OrdersApiTests(WebApplicationFactory<Program> factory) { _client = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Ganti database asli dengan in-memory database khusus test services.RemoveAll<DbContextOptions<AppDbContext>>(); services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase("TestDb")); }); }).CreateClient(); } [Fact] public async Task GetOrders_MengembalikanStatus200() { var response = await _client.GetAsync("/api/orders"); response.EnsureSuccessStatusCode(); Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType?.ToString()); } } // Test ini menjalankan aplikasi ASP.NET Core sungguhan secara in-memory, lengkap dengan pipeline

Unit test dengan mocking seperti yang dibahas pada round sebelumnya memverifikasi logika bisnis secara terisolasi, tetapi tidak menjamin bahwa routing, middleware, model binding, dan serialisasi JSON benar-benar bekerja sama dengan baik ketika digabungkan dalam satu aplikasi utuh. WebApplicationFactory dari package Microsoft.AspNetCore.Mvc.Testing menyediakan cara menjalankan seluruh pipeline aplikasi secara in-memory tanpa perlu meng-hosting aplikasi secara terpisah di server sungguhan. Class Program pada project minimal hosting model perlu ditandai public partial agar bisa diakses oleh assembly test secara langsung.

Method WithWebHostBuilder memungkinkan override konfigurasi service khusus untuk kebutuhan test, paling umum dipakai untuk mengganti DbContext yang terhubung ke SQL Server sungguhan dengan UseInMemoryDatabase, sehingga test integrasi tidak bergantung pada ketersediaan database eksternal dan bisa dijalankan berulang kali dengan state yang bersih setiap kali test dimulai. HttpClient yang dihasilkan dari factory ini berperilaku persis seperti client yang memanggil API sungguhan lewat jaringan. Header seperti Authorization pun bisa ditambahkan langsung ke HttpClient ini sebelum memanggil endpoint yang membutuhkan autentikasi.

Integration test jenis ini sangat berguna untuk memverifikasi bahwa kontrak API benar-benar sesuai ekspektasi, termasuk status code, format response, dan header seperti Content-Type, yang tidak bisa diuji lewat unit test murni terhadap service tanpa HTTP pipeline. Karena menjalankan lebih banyak komponen sekaligus, integration test cenderung lebih lambat dibanding unit test, sehingga biasanya ditempatkan pada tahap CI/CD terpisah dan dijalankan lebih jarang dibanding unit test yang dijalankan di setiap commit. Kombinasi unit test untuk logika murni dan integration test untuk kontrak API memberi cakupan pengujian yang lebih menyeluruh dibanding mengandalkan salah satunya saja.

In-Memory Database vs SQLite In-Memory untuk Integration Test EF Core

services.AddDbContext<AppDbContext>(options => options.UseSqlite("DataSource=:memory:")); // Koneksi harus dibuka manual dan dijaga tetap terbuka selama test berjalan var connection = new SqliteConnection("DataSource=:memory:"); connection.Open(); // Provider UseInMemoryDatabase tidak menegakkan foreign key dan constraint // seperti database relasional sungguhan

Provider UseInMemoryDatabase bawaan EF Core memang mudah dipakai, tapi tidak memvalidasi constraint seperti foreign key, unique index, atau concurrency token sebagaimana database relasional sungguhan, sehingga query yang seharusnya gagal dijalankan di SQL Server bisa saja lolos begitu saja saat diuji dengan provider ini. Hal tersebut membuat sebagian bug baru ketahuan setelah aplikasi benar-benar di-deploy ke lingkungan sungguhan.

SQLite in-memory jauh lebih mendekati perilaku database relasional karena benar-benar menjalankan perintah SQL dan menegakkan constraint sebagaimana mestinya, meski tetap ada sedikit perbedaan sintaks dibanding SQL Server untuk fitur-fitur tertentu. Untuk pengujian yang memang menguji query kompleks atau aturan integritas data secara ketat, SQLite in-memory memberikan hasil yang jauh lebih dapat dipercaya dibanding sekadar memakai provider in-memory biasa.

Kesalahan Umum yang Sering Terjadi

Menulis Test yang Bergantung pada Database Sungguhan

Unit test yang terhubung ke database asli menjadi lambat dan rapuh karena bergantung pada kondisi data yang bisa berubah; sebaiknya memakai mock atau in-memory database untuk menjaga test tetap cepat dan terisolasi.

Satu Test Memeriksa Terlalu Banyak Hal Sekaligus

Test yang memiliki banyak Assert untuk berbagai aspek berbeda dalam satu method membuat sulit mengetahui bagian mana yang sebenarnya gagal ketika test tersebut tidak lolos, sebaiknya dipecah menjadi beberapa test yang lebih fokus.

Mengabaikan Test yang Gagal Secara Konsisten

Membiarkan test yang gagal tetap ada tanpa diperbaiki atau dihapus, atau menandainya sebagai Skip tanpa alasan jelas, mengurangi kepercayaan tim terhadap seluruh suite test dan bisa menyembunyikan bug nyata yang sebenarnya perlu diperbaiki.

Kapan Pendekatan Ini Tidak Cocok Dipakai

Unit test membutuhkan waktu tambahan untuk ditulis di awal, namun memberikan jaring pengaman yang sangat berharga ketika kode diubah di kemudian hari, memastikan perubahan tidak merusak fungsionalitas yang sudah ada sebelumnya tanpa disadari.

Mocking memungkinkan test berjalan cepat dan terisolasi dari dependensi eksternal, namun test yang terlalu banyak memakai mock bisa kehilangan kemampuan mendeteksi masalah integrasi nyata antar komponen, sehingga tetap perlu dilengkapi dengan integration test untuk skenario end-to-end yang lebih realistis.

Ringkasan

Unit testing dengan xUnit dan Moq memungkinkan logika aplikasi diuji secara terisolasi dan cepat, dengan pola Arrange-Act-Assert memberikan struktur yang jelas pada setiap test. Investasi waktu menulis unit test yang baik akan terbayar dalam bentuk kepercayaan diri yang lebih tinggi saat melakukan perubahan kode di masa depan.

Seri Belajar .NET Core:

← Sebelumnya: Exception Handling Global Middleware

→ Selanjutnya: Deployment ASP.NET Core ke IIS atau Cloud