Apakah ada cara yang lebih mudah untuk menguji validasi argumen dan inisialisasi bidang dalam objek yang tidak berubah?

20

Domain saya terdiri dari banyak kelas sederhana yang tidak dapat diubah seperti ini:

public class Person
{
    public string FullName { get; }
    public string NameAtBirth { get; }
    public string TaxId { get; }
    public PhoneNumber PhoneNumber { get; }
    public Address Address { get; }

    public Person(
        string fullName,
        string nameAtBirth,
        string taxId,
        PhoneNumber phoneNumber,
        Address address)
    {
        if (fullName == null)
            throw new ArgumentNullException(nameof(fullName));
        if (nameAtBirth == null)
            throw new ArgumentNullException(nameof(nameAtBirth));
        if (taxId == null)
            throw new ArgumentNullException(nameof(taxId));
        if (phoneNumber == null)
            throw new ArgumentNullException(nameof(phoneNumber));
        if (address == null)
            throw new ArgumentNullException(nameof(address));

        FullName = fullName;
        NameAtBirth = nameAtBirth;
        TaxId = taxId;
        PhoneNumber = phoneNumber;
        Address = address;
    }
}

Menulis cek nol dan inisialisasi properti sudah sangat membosankan tetapi saat ini saya menulis tes unit untuk masing-masing kelas untuk memverifikasi bahwa validasi argumen berfungsi dengan benar dan bahwa semua properti diinisialisasi. Ini terasa seperti pekerjaan yang sangat membosankan dengan keuntungan yang tidak sepadan.

Solusi sebenarnya adalah untuk C # untuk mendukung tipe referensi immutability dan non-nullable. Tetapi apa yang dapat saya lakukan untuk memperbaiki situasi sementara itu? Apakah layak menulis semua tes ini? Apakah ide yang baik untuk menulis generator kode untuk kelas seperti itu untuk menghindari tes menulis untuk masing-masing dari mereka?


Inilah yang saya miliki sekarang berdasarkan jawaban.

Saya dapat menyederhanakan cek kosong dan inisialisasi properti agar terlihat seperti ini:

FullName = fullName.ThrowIfNull(nameof(fullName));
NameAtBirth = nameAtBirth.ThrowIfNull(nameof(nameAtBirth));
TaxId = taxId.ThrowIfNull(nameof(taxId));
PhoneNumber = phoneNumber.ThrowIfNull(nameof(phoneNumber));
Address = address.ThrowIfNull(nameof(address));

Menggunakan implementasi berikut oleh Robert Harvey :

public static class ArgumentValidationExtensions
{
    public static T ThrowIfNull<T>(this T o, string paramName) where T : class
    {
        if (o == null)
            throw new ArgumentNullException(paramName);

        return o;
    }
}

Menguji cek nol mudah menggunakan GuardClauseAssertiondari AutoFixture.Idioms(terima kasih atas sarannya, Esben Skov Pedersen ):

var fixture = new Fixture().Customize(new AutoMoqCustomization());
var assertion = new GuardClauseAssertion(fixture);
assertion.Verify(typeof(Address).GetConstructors());

Ini dapat dikompresi lebih jauh:

typeof(Address).ShouldNotAcceptNullConstructorArguments();

Menggunakan metode ekstensi ini:

public static void ShouldNotAcceptNullConstructorArguments(this Type type)
{
    var fixture = new Fixture().Customize(new AutoMoqCustomization());
    var assertion = new GuardClauseAssertion(fixture);

    assertion.Verify(type.GetConstructors());
}
Botond Balázs
sumber
3
Judul / tag berbicara tentang (unit) pengujian nilai-nilai bidang yang menyiratkan pengujian unit pasca-konstruksi objek, tetapi potongan kode menunjukkan argumen input / validasi parameter; ini bukan konsep yang sama.
Erik Eidt
2
FYI, Anda bisa menulis template T4 yang akan membuat boilerplate jenis ini mudah. (Anda mungkin juga mempertimbangkan string.IsEmpty () di luar == null.)
Erik Eidt
1
Apakah Anda sudah melihat idiom perbaikan otomatis? nuget.org/packages/AutoFixture.Idioms
Esben Skov Pedersen
1
Anda juga bisa menggunakan Fody / NullGuard , meskipun sepertinya tidak memiliki mode default-allow.
Bob

Jawaban:

5

Saya membuat template t4 untuk kasus-kasus seperti ini. Untuk menghindari menulis banyak boilerplate untuk kelas Immutable.

https://github.com/xaviergonz/T4Immutable T4Immutable adalah template T4 untuk C #. Aplikasi NET yang menghasilkan kode untuk kelas yang tidak dapat diubah.

Khususnya berbicara tentang tes tidak nol maka jika Anda menggunakan ini:

[PreNotNullCheck, PostNotNullCheck]
public string FirstName { get; }

Konstruktornya adalah sebagai berikut:

public Person(string firstName) {
  // pre not null check
  if (firstName == null) throw new ArgumentNullException(nameof(firstName));

  // assignations + PostConstructor() if needed

  // post not null check
  if (this.FirstName == null) throw new NullReferenceException(nameof(this.FirstName));
}

Karena itu, jika Anda menggunakan JetBrains Annotations untuk pemeriksaan nol, Anda juga dapat melakukan ini:

[JetBrains.Annotations.NotNull, ConstructorParamNotNull]
public string FirstName { get; }

Dan konstruktornya adalah sebagai berikut:

public Person([JetBrains.Annotations.NotNull] string firstName) {
  // pre not null check is implied by ConstructorParamNotNull
  if (firstName == null) throw new ArgumentNullException(nameof(firstName));

  FirstName = firstName;
  // + PostConstructor() if needed

  // post not null check implied by JetBrains.Annotations.NotNull on the property
  if (this.FirstName == null) throw new NullReferenceException(nameof(this.FirstName));
}

Juga ada beberapa fitur lebih banyak dari yang ini.

Javier G.
sumber
Terima kasih. Saya baru saja mencobanya dan itu bekerja dengan sempurna. Saya menandai ini sebagai jawaban yang diterima karena menjawab semua masalah dalam pertanyaan awal.
Botond Balázs
16

Anda bisa mendapatkan sedikit peningkatan dengan refactoring sederhana yang dapat meringankan masalah penulisan semua pagar itu. Pertama, Anda memerlukan metode ekstensi ini:

internal static T ThrowIfNull<T>(this T o, string paramName) where T : class
{
    if (o == null)
        throw new ArgumentNullException(paramName);

    return o;
}

Anda kemudian dapat menulis:

public class Person
{
    public string FullName { get; }
    public string NameAtBirth { get; }
    public string TaxId { get; }
    public PhoneNumber PhoneNumber { get; }
    public Address Address { get; }

    public Person(
        string fullName,
        string nameAtBirth,
        string taxId,
        PhoneNumber phoneNumber,
        Address address)
    {
        FullName = fullName.ThrowIfNull(nameof(fullName));
        NameAtBirth = nameAtBirth.ThrowIfNull(nameof(nameAtBirth));
        TaxId = taxId.ThrowIfNull(nameof(taxId));
        PhoneNumber = phoneNumber.ThrowIfNull(nameof(fullName));
        Address = address.ThrowIfNull(nameof(address));
    }
}

Mengembalikan parameter asli dalam metode ekstensi menciptakan antarmuka yang lancar, sehingga Anda dapat memperluas konsep ini dengan metode ekstensi lainnya jika diinginkan, dan menyatukan semuanya dalam tugas Anda.

Teknik lain lebih elegan dalam konsep, tetapi semakin kompleks dalam eksekusi, seperti mendekorasi parameter dengan [NotNull]atribut, dan menggunakan Refleksi seperti ini .

Yang mengatakan, Anda mungkin tidak memerlukan semua tes ini, kecuali kelas Anda adalah bagian dari API yang dihadapi publik.

Robert Harvey
sumber
3
Aneh bahwa ini diturunkan. Ini sangat mirip dengan pendekatan yang diberikan oleh jawaban yang paling banyak dipilih, kecuali bahwa itu tidak bergantung pada Roslyn.
Robert Harvey
Apa yang saya tidak suka tentang ini adalah bahwa itu rentan terhadap modifikasi konstruktor.
jpmc26
@ jpmc26: Apa artinya itu?
Robert Harvey
1
Ketika saya membaca jawaban Anda, itu menyarankan agar Anda tidak menguji konstruktor , tetapi Anda membuat beberapa metode umum yang dipanggil pada setiap parameter dan mengujinya. Jika Anda menambahkan pasangan properti / argumen baru dan lupa untuk menguji nullpada argumen ke konstruktor, Anda tidak akan mendapatkan tes yang gagal.
jpmc26
@ jpmc26: Secara alami. Tetapi itu juga benar jika Anda menuliskannya dengan cara konvensional seperti yang diilustrasikan dalam OP. Semua metode umum yang dilakukan adalah memindahkan bagian throwluar konstruktor; Anda tidak benar-benar mentransfer kontrol di dalam konstruktor di tempat lain. Itu hanya metode utilitas, dan cara untuk melakukan berbagai hal dengan lancar , jika Anda mau.
Robert Harvey
7

Dalam jangka pendek, tidak banyak yang dapat Anda lakukan tentang kebosanan menulis tes semacam itu. Namun, ada beberapa bantuan yang datang dengan ekspresi lemparan yang akan diimplementasikan sebagai bagian dari rilis C # (v7) berikutnya, kemungkinan jatuh tempo dalam beberapa bulan ke depan:

public class Person
{
    public string FullName { get; }
    public string NameAtBirth { get; }
    public string TaxId { get; }
    public PhoneNumber PhoneNumber { get; }
    public Address Address { get; }

    public Person(
        string fullName,
        string nameAtBirth,
        string taxId,
        PhoneNumber phoneNumber,
        Address address)
    {
        FullName = fullName ?? throw new ArgumentNullException(nameof(fullName));
        NameAtBirth = nameAtBirth ?? throw new ArgumentNullException(nameof(nameAtBirth));
        TaxId = taxId ?? throw new ArgumentNullException(nameof(taxId)); ;
        PhoneNumber = phoneNumber ?? throw new ArgumentNullException(nameof(phoneNumber)); ;
        Address = address ?? throw new ArgumentNullException(nameof(address)); ;
    }
}

Anda dapat bereksperimen dengan membuang ekspresi melalui webapp Try Roslyn .

David Arno
sumber
Jauh lebih kompak, terima kasih. Setengah lebih banyak garis. Menantikan C # 7!
Botond Balázs
@ BotondBalázs Anda dapat mencobanya di pratinjau VS15 sekarang juga jika Anda mau
user1306322
7

Saya terkejut tidak ada yang menyebutkan NullGuard . Belum ada yang tahu. Ini tersedia melalui NuGet dan secara otomatis akan menenun cek nol itu ke IL selama waktu kompilasi.

Jadi kode konstruktor Anda akan menjadi sederhana

public Person(
    string fullName,
    string nameAtBirth,
    string taxId,
    PhoneNumber phoneNumber,
    Address address)
{
    FullName = fullName;
    NameAtBirth = nameAtBirth;
    TaxId = taxId;
    PhoneNumber = phoneNumber;
    Address = address;
}

dan NullGuard akan menambahkan cek nol itu untuk Anda mentransformasikannya menjadi apa yang Anda tulis.

Perhatikan bahwa NullGuard adalah opt-out , yaitu, ia akan menambahkan cek nol itu untuk setiap metode dan argumen konstruktor, pengambil properti dan penyetel dan bahkan memeriksa nilai metode pengembalian kecuali jika Anda secara eksplisit mengizinkan nilai nol dengan [AllowNull]atribut.

Roman Reiner
sumber
Terima kasih, ini sepertinya solusi umum yang sangat bagus. Meskipun sangat disayangkan bahwa memodifikasi perilaku bahasa secara default. Saya akan lebih menyukainya jika bekerja dengan menambahkan [NotNull]atribut alih-alih tidak membiarkan null menjadi default.
Botond Balázs
@ BotondBalázs: PostSharp memiliki ini bersama dengan atribut validasi parameter lainnya. Tidak seperti Fody, itu tidak gratis. Juga, kode yang dihasilkan akan memanggil ke DLL PostSharp sehingga Anda harus menyertakan yang dengan produk Anda. Fody hanya akan menjalankan kode selama kompilasi dan tidak akan diperlukan saat runtime.
Roman Reiner
@ BotondBalázs: Saya juga merasa agak aneh pada awalnya bahwa NullGuard berlaku per default tetapi masuk akal juga. Dalam basis kode apa pun yang memungkinkan, null harus jauh lebih jarang daripada memeriksa null. Jika Anda benar - benar ingin membiarkan nol di suatu tempat pendekatan opt-out memaksa Anda untuk sangat teliti dengannya.
Roman Reiner
5
Ya, itu masuk akal tetapi melanggar Prinsip Terkecil Ketertarikan . Jika seorang programmer baru tidak tahu bahwa proyek tersebut menggunakan NullGuard, mereka akan terkejut! Omong-omong, saya juga tidak mengerti downvote, ini adalah jawaban yang sangat berguna.
Botond Balázs
1
@ BotondBalázs / Roman, sepertinya NullGuard memiliki mode eksplisit baru yang mengubah cara kerjanya secara default
Kyle W
5

Apakah layak menulis semua tes ini?

Tidak, mungkin juga tidak. Apa kemungkinan Anda akan mengacaukannya? Apa kemungkinan beberapa semantik akan berubah dari bawah Anda? Apa dampaknya jika seseorang mengacaukannya?

Jika Anda menghabiskan banyak waktu membuat tes untuk sesuatu yang jarang akan pecah, dan merupakan perbaikan sepele jika itu ... mungkin tidak sepadan.

Apakah ide yang baik untuk menulis generator kode untuk kelas seperti itu untuk menghindari tes menulis untuk masing-masing dari mereka?

Mungkin? Hal semacam itu bisa dilakukan dengan mudah dengan refleksi. Sesuatu yang perlu dipertimbangkan adalah melakukan pembuatan kode untuk kode nyata, sehingga Anda tidak memiliki kelas N yang mungkin memiliki kesalahan manusia. Pencegahan bug> deteksi bug.

Telastyn
sumber
Terima kasih. Mungkin saya tidak cukup jelas dalam pertanyaan saya, tetapi saya bermaksud menulis generator yang menghasilkan kelas yang tidak dapat diubah, bukan tes untuk mereka
Botond Balázs
2
Saya juga telah melihat beberapa kali alih-alih yang if...throwmereka akan miliki ThrowHelper.ArgumentNull(myArg, "myArg"), dengan cara itu logikanya masih ada dan Anda tidak terpengaruh dengan cakupan kode. Di mana ArgumentNullmetode akan melakukan if...throw.
Matius
2

Apakah layak menulis semua tes ini?

Tidak.
Karena saya cukup yakin Anda telah menguji sifat-sifat tersebut melalui beberapa tes logika lain di mana kelas-kelas tersebut digunakan.

Misalnya, Anda dapat memiliki tes untuk kelas Pabrik yang memiliki pernyataan berdasarkan pada properti tersebut (Contoh dibuat dengan Nameproperti yang ditugaskan dengan benar misalnya).

Jika kelas-kelas tersebut terpapar ke API publik yang digunakan oleh beberapa pengguna bagian / akhir ketiga (@EJoshua terima kasih atas perhatiannya), maka tes untuk yang diharapkan ArgumentNullExceptiondapat berguna.

Sambil menunggu C # 7 Anda dapat menggunakan metode ekstensi

public MyClass(string name)
{
    name.ThrowArgumentNullExceptionIfNull(nameof(name));
}

public static void ThrowArgumentNullExceptionIfNull(this object value, string paramName)
{
    if(value == null)
        throw new ArgumentNullException(paramName);
}

Untuk pengujian, Anda dapat menggunakan satu metode pengujian berparameter yang menggunakan refleksi untuk membuat nullreferensi untuk setiap parameter dan menegaskan pengecualian yang diharapkan.

Fabio
sumber
1
Itu argumen yang meyakinkan untuk tidak menguji inisialisasi properti.
Botond Balázs
Itu tergantung pada bagaimana Anda menggunakan kode. Jika kode tersebut adalah bagian dari perpustakaan yang dihadapkan kepada publik, Anda tidak boleh secara buta memercayai orang lain untuk mengetahui cara perpustakaan Anda (terutama jika mereka tidak memiliki akses ke kode sumber); namun, jika itu adalah sesuatu yang hanya Anda gunakan secara internal untuk membuat kode Anda berfungsi, Anda harus tahu cara menggunakan kode Anda sendiri, sehingga tujuan pemeriksaan kurang.
EJoshuaS
2

Anda selalu dapat menulis metode seperti berikut:

// Just to illustrate how to call this
private static void SomeMethod(string a, string b, string c, string d)
    {
        ValidateArguments(a, b, c, d);
        // ...
    }

    // This is the one to use as a utility function
    private static void ValidateArguments(params object[] args)
    {
        for (int i = 0; i < args.Length; i++)
        {
            if (args[i] == null)
            {
                StackTrace trace = new StackTrace();
                // Get the method that called us
                MethodBase info = trace.GetFrame(1).GetMethod();

                // Get information on the parameter that is null so we can add its name to the exception
                ParameterInfo param = info.GetParameters()[i];

                // Raise the exception on behalf of the caller
                throw new ArgumentNullException(param.Name);
            }
        }
    }

Minimal, itu akan menghemat beberapa pengetikan jika Anda memiliki beberapa metode yang memerlukan validasi semacam ini. Tentu saja, solusi ini mengasumsikan bahwa tidak ada parameter metode Anda bisa null, tetapi Anda dapat memodifikasi ini untuk mengubahnya jika Anda menginginkannya.

Anda juga dapat memperluas ini untuk melakukan validasi tipe spesifik lainnya. Misalnya, jika Anda memiliki aturan bahwa string tidak boleh murni atau kosong, Anda bisa menambahkan kondisi berikut:

// Note that we already know based on the previous condition that args[i] is not null
else if (args[i].GetType() == typeof(string))
            {
                string argCast = arg as string;

                if (!argCast.Trim().Any())
                {
                    ParameterInfo param = GetParameterInfo(i);

                    throw new ArgumentException(param.Name + " is empty or consists only of whitespace");
                }
            }

Metode GetParameterInfo yang saya rujuk pada dasarnya melakukan refleksi (sehingga saya tidak harus terus mengetik hal yang sama berulang-ulang, yang akan melanggar prinsip KERING ):

private static ParameterInfo GetParameterInfo(int index)
    {
        StackTrace trace = new StackTrace();

        // Note that we have to go 2 methods back to get the ValidateArguments method's caller
        MethodBase info = trace.GetFrame(2).GetMethod();

        // Get information on the parameter that is null so we can add its name to the exception
        ParameterInfo param = info.GetParameters()[index];

        return param;
    }
EJoshuaS - Pasang kembali Monica
sumber
1
Mengapa ini diturunkan? Itu tidak terlalu buruk
Gaspa79
0

Saya tidak menyarankan untuk melempar pengecualian dari konstruktor. Masalahnya adalah Anda akan mengalami kesulitan untuk menguji ini karena Anda harus melewati parameter yang valid bahkan mereka tidak relevan untuk pengujian Anda.

Misalnya: Jika Anda ingin menguji apakah parameter ketiga melempar pengecualian, Anda harus memasukkan yang pertama dan yang kedua dengan benar. Jadi tes Anda tidak terisolasi lagi. ketika kendala dari parameter pertama dan kedua berubah, test case ini akan gagal bahkan itu menguji parameter ketiga.

Saya menyarankan untuk menggunakan API validasi java dan mengeksternalisasi proses validasi. Saya menyarankan untuk melibatkan empat tanggung jawab (kelas):

  1. Objek saran dengan parameter validasi Java yang dianotasikan dengan metode validasi yang mengembalikan Set ConstraintViolations. Salah satu keuntungannya adalah Anda bisa mengedarkan objek ini tanpa pernyataan yang valid. Anda dapat menunda validasi hingga diperlukan tanpa harus mencoba untuk instantiate objek domain. Objek validasi dapat digunakan dalam berbagai lapisan karena merupakan POJO tanpa pengetahuan khusus lapisan. Itu bisa menjadi bagian dari API publik Anda.

  2. Pabrik untuk objek domain yang bertanggung jawab untuk membuat objek yang valid. Anda meneruskan objek saran dan pabrik akan memanggil validasi dan membuat objek domain jika semuanya baik-baik saja.

  3. Objek domain itu sendiri yang sebagian besar tidak dapat diakses untuk instantiasi untuk pengembang lain. Untuk tujuan pengujian saya sarankan untuk memiliki kelas ini dalam lingkup paket.

  4. Antarmuka domain publik untuk menyembunyikan objek domain konkret dan membuat penyalahgunaan menjadi sulit.

Saya tidak menyarankan untuk memeriksa nilai nol. Segera setelah Anda masuk ke lapisan domain Anda telah menyingkirkan melewati nol atau mengembalikan nol selama Anda tidak memiliki rantai objek yang memiliki ujung atau fungsi pencarian untuk objek tunggal.

Satu hal lain: menulis kode sesedikit mungkin bukan metrik untuk kualitas kode. Pikirkan tentang kompetisi game 32k. Kode itu adalah yang paling ringkas tetapi juga kode paling berantakan yang bisa Anda miliki karena hanya peduli pada masalah teknis dan tidak peduli dengan semantik. Tapi semantik adalah poin yang membuat semuanya komprehensif.

oopexpert
sumber
1
Dengan hormat saya tidak setuju dengan poin terakhir Anda. Tidak harus mengetikkan boilerplate yang sama untuk ke-100 kalinya memang membantu mengurangi kemungkinan melakukan kesalahan. Bayangkan jika ini Java, bukan C #. Saya harus mendefinisikan bidang dukungan dan metode pengambil untuk setiap properti. Kode akan menjadi benar-benar tidak dapat dibaca dan yang penting akan dikaburkan oleh infrastruktur yang kikuk.
Botond Balázs