Bagaimana Anda menyusun tes unit untuk beberapa objek yang menunjukkan perilaku yang sama?

9

Dalam banyak kasus saya mungkin memiliki kelas yang sudah ada dengan beberapa perilaku:

class Lion
{
    public void Eat(Herbivore herbivore) { ... }
}

... dan saya memiliki unit test ...

[TestMethod]
public void Lion_can_eat_herbivore()
{
    var herbivore = buildHerbivoreForEating();
    var test = BuildLionForTest();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

Sekarang, apa yang terjadi adalah saya perlu membuat kelas Tiger dengan perilaku yang identik dengan singa:

class Tiger
{
    public void Eat(Herbivore herbivore) { ... }
}

... dan karena saya ingin perilaku yang sama, saya perlu menjalankan tes yang sama, saya melakukan sesuatu seperti ini:

interface IHerbivoreEater
{
    void Eat(Herbivore herbivore);
}

... dan saya memperbaiki pengujian saya:

[TestMethod]
public void Lion_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildLionForTest);
}


public void IHerbivoreEater_can_eat_herbivore(Func<IHerbivoreEater> builder)
{
    var herbivore = buildHerbivoreForEating();
    var test = builder();
    test.Eat(herbivore);
    Assert.IsEaten(herbivore);
}

... dan kemudian saya menambahkan tes lain untuk Tigerkelas baru saya :

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

... dan kemudian saya memperbaiki kelas saya Liondan Tiger(biasanya dengan warisan, tetapi kadang-kadang dengan komposisi):

class Lion : HerbivoreEater { }
class Tiger : HerbivoreEater { }

abstract class HerbivoreEater : IHerbivoreEater
{
    public void Eat(Herbivore herbivore) { ... }
}

... dan semuanya baik-baik saja. Namun, karena fungsionalitasnya sekarang ada di HerbivoreEaterkelas, sekarang rasanya ada yang salah dengan melakukan tes untuk masing-masing perilaku ini di setiap subkelas. Namun itu adalah subclass yang benar-benar dikonsumsi, dan itu hanya detail implementasi bahwa mereka berbagi perilaku yang tumpang tindih ( Lionsdan Tigersmungkin memiliki penggunaan akhir yang sama sekali berbeda, misalnya).

Tampaknya berlebihan untuk menguji kode yang sama beberapa kali, tetapi ada kasus di mana subclass dapat dan tidak menimpa fungsionalitas kelas dasar (ya, itu mungkin melanggar LSP, tetapi mari kita hadapi itu, IHerbivoreEaterhanya antarmuka pengujian yang nyaman - itu mungkin tidak masalah bagi pengguna akhir). Jadi tes ini memang memiliki nilai, saya pikir.

Apa yang dilakukan orang lain dalam situasi ini? Apakah Anda hanya memindahkan tes Anda ke kelas dasar, atau apakah Anda menguji semua subclass untuk perilaku yang diharapkan?

EDIT :

Berdasarkan jawaban dari @ pdr saya pikir kita harus mempertimbangkan ini: IHerbivoreEateritu hanya kontrak metode tanda tangan; itu tidak menentukan perilaku. Contohnya:

[TestMethod]
public void Tiger_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildTigerForTest);
}

[TestMethod]
public void Cheetah_eats_herbivore_haunches_first()
{
    IHerbivoreEater_eats_herbivore_haunches_first(BuildCheetahForTest);
}

[TestMethod]
public void Lion_eats_herbivore_head_first()
{
    IHerbivoreEater_eats_herbivore_head_first(BuildLionForTest);
}
Scott Whitlock
sumber
Demi argumen, seharusnya tidak Anda memiliki Animalkelas yang berisi Eat? Semua hewan makan, dan karenanya kelas Tigerdan Liondapat mewarisi dari hewan.
The Muffin Man
1
@Nick - itu adalah poin yang bagus, tapi saya pikir itu situasi yang berbeda. Seperti @pdr tunjukkan, jika Anda menempatkan Eatperilaku di kelas dasar, maka semua subclass harus menunjukkan Eatperilaku yang sama . Namun, saya berbicara tentang 2 kelas yang relatif tidak berhubungan yang terjadi untuk berbagi perilaku. Pertimbangkan, misalnya, Flyperilaku Brickdan Personyang, dapat kita asumsikan, menunjukkan perilaku terbang yang serupa, tetapi tidak selalu masuk akal untuk membuat mereka berasal dari kelas dasar yang sama.
Scott Whitlock

Jawaban:

6

Ini bagus karena ini menunjukkan bagaimana tes benar-benar mendorong cara Anda berpikir tentang desain. Anda merasakan masalah dalam desain dan mengajukan pertanyaan yang tepat.

Ada dua cara untuk melihatnya.

IHerbivoreEater adalah kontrak. Semua IHerbivoreEaters harus memiliki metode Makan yang menerima Herbivore. Sekarang, tes Anda tidak peduli bagaimana dimakan; Singa Anda mungkin mulai dengan paha dan Tiger mungkin mulai dari tenggorokan. Semua tes Anda peduli adalah bahwa setelah itu disebut Makan, Herbivore dimakan.

Di sisi lain, bagian dari apa yang Anda katakan adalah bahwa semua IHerbivoreEaters memakan Herbivore dengan cara yang persis sama (karenanya kelas dasar). Karena itu, tidak ada gunanya memiliki kontrak IHerbivoreEater sama sekali. Itu tidak menawarkan apa pun. Anda mungkin juga mewarisi dari HerbivoreEater.

Atau mungkin menyingkirkan Lion and Tiger sepenuhnya.

Tetapi, jika Lion dan Tiger berbeda dalam segala hal selain dari kebiasaan makan mereka, maka Anda perlu mulai bertanya-tanya apakah Anda akan mengalami masalah dengan pohon warisan yang kompleks. Bagaimana jika Anda juga ingin menurunkan kedua kelas dari Feline, atau hanya kelas Lion dari KingOfItsDomain (bersama dengan Shark, mungkin). Di sinilah LSP benar-benar masuk.

Saya menyarankan bahwa kode umum lebih baik dienkapsulasi.

public class Lion : IHerbivoreEater
{
    private IHerbivoreEatingStrategy _herbivoreEatingStrategy;
    private Lion (IHerbivoreEatingStrategy herbivoreEatingStrategy)
    {
        _herbivoreEatingStrategy = herbivoreEatingStrategy;
    }

    public Lion() : this(new StandardHerbivoreEatingStrategy())
    {
    }

    public void Eat(Herbivore herbivore)
    {
        _herbivoreEatingStrategy.Eat(herbivore);
    }
}

Hal yang sama berlaku untuk Tiger.

Sekarang, ini adalah hal yang indah berkembang (cantik karena saya tidak bermaksud). Jika Anda hanya membuat konstruktor pribadi yang tersedia untuk pengujian, Anda bisa meneruskan IHerbivoreEatingStrategy palsu dan cukup menguji bahwa pesan tersebut diteruskan ke objek yang dienkapsulasi dengan benar.

Dan tes kompleks Anda, yang Anda khawatirkan sejak awal, hanya perlu menguji StandardHerbivoreEatingStrategy. Satu kelas, satu set tes, tidak ada kode duplikat untuk dikhawatirkan.

Dan jika, nanti, Anda ingin memberi tahu harimau bahwa mereka harus memakan herbivora dengan cara yang berbeda, tidak ada tes yang harus diubah. Anda cukup membuat HerbivoreEatingStrategy baru dan mengujinya. Pengkabelan diuji pada tingkat uji integrasi.

pdr
sumber
+1 Pola Strategi adalah hal pertama yang muncul di benak saya membaca pertanyaan.
StuperUser
Sangat bagus, tetapi sekarang ganti "unit test" dalam pertanyaan saya dengan "tes integrasi". Bukankah kita berakhir dengan masalah yang sama? IHerbivoreEateradalah kontrak, tetapi hanya sebanyak yang saya butuhkan untuk pengujian. Menurut saya ini adalah salah satu kasus di mana mengetik bebek akan sangat membantu. Saya hanya ingin mengirim keduanya ke logika tes yang sama sekarang. Saya tidak berpikir antarmuka harus menjanjikan perilaku. Tes harus melakukan itu.
Scott Whitlock
pertanyaan bagus, jawaban bagus. Anda tidak harus memiliki konstruktor pribadi; Anda dapat mengirim IHerbivoreEatingStrategy ke StandardHerbivoreEatingStrategy menggunakan wadah IoC.
azheglov
@ScottWhitlock: "Saya tidak berpikir antarmuka harus menjanjikan perilaku. Tes harus melakukan itu." Itulah tepatnya yang saya katakan. Jika itu benar-benar menjanjikan perilaku, Anda harus menyingkirkannya dan cukup gunakan kelas (dasar). Anda tidak memerlukannya untuk pengujian sama sekali.
pdr
@azheglov: Setuju, tapi jawaban saya cukup lama sudah :)
pdr
1

Tampaknya berlebihan untuk menguji kode yang sama beberapa kali, tetapi ada kasus di mana subclass dapat dan tidak mengesampingkan fungsionalitas kelas dasar

Secara keseluruhan, Anda bertanya apakah layak menggunakan pengetahuan kotak putih untuk menghilangkan beberapa tes. Dari perspektif kotak hitam, Liondan Tigerkelas yang berbeda. Jadi seseorang yang tidak terbiasa dengan kode akan mengujinya, tetapi Anda dengan pengetahuan implementasi yang mendalam tahu bahwa Anda bisa lolos hanya dengan menguji satu hewan.

Bagian dari alasan untuk mengembangkan unit test adalah untuk memungkinkan diri Anda untuk melakukan refactor nanti tetapi mempertahankan antarmuka black-box yang sama . Tes unit membantu Anda memastikan kelas Anda terus memenuhi kontrak mereka dengan klien, atau paling tidak memaksa Anda untuk menyadari dan berpikir hati-hati bagaimana kontrak mungkin berubah. Anda sendiri menyadari hal itu Lionatau Tigermungkin menimpa Eatdi beberapa titik nanti. Jika ini memungkinkan, uji unit sederhana yang dapat dimakan setiap hewan yang Anda makan, dengan cara:

[TestMethod]
public void Tiger_can_eat_herbivore()
{
    IHerbivoreEater_can_eat_herbivore(BuildTigerForTest);
}

harus sangat sederhana untuk dilakukan dan cukup dan akan memastikan bahwa Anda dapat mendeteksi ketika benda gagal memenuhi kontrak mereka.

Doug T.
sumber
Saya bertanya-tanya apakah pertanyaan ini benar-benar hanya mengarah ke preferensi pengujian kotak hitam vs kotak putih. Saya bersandar ke kamp kotak hitam, yang mungkin mengapa saya menguji cara saya. Terima kasih telah menunjukkannya.
Scott Whitlock
1

Kamu melakukannya dengan benar. Pikirkan uji unit sebagai tes perilaku penggunaan satu kode baru Anda. Ini adalah panggilan yang sama dengan yang Anda lakukan dari kode produksi.

Dalam situasi itu, Anda sepenuhnya benar; pengguna Lion atau Tiger tidak akan (tidak harus, setidaknya) peduli bahwa mereka berdua HerbivoreEaters dan bahwa kode yang benar-benar berjalan untuk metode ini adalah umum untuk keduanya di kelas dasar. Demikian pula, pengguna abstrak HerbivoreEater (disediakan oleh Lion atau Tiger beton) tidak akan peduli dengan apa yang dimilikinya. Apa yang mereka pedulikan adalah bahwa Singa, Harimau, atau implementasi konkret HerbivoreEater mereka yang tidak diketahui akan memakan () seekor herbivora dengan benar.

Jadi, yang pada dasarnya Anda uji adalah bahwa Singa akan Makan sebagaimana dimaksud, dan bahwa Harimau akan Makan sebagaimana dimaksud. Penting untuk menguji keduanya, karena mungkin tidak selalu benar bahwa mereka berdua makan dengan cara yang persis sama; dengan menguji keduanya, Anda memastikan bahwa yang Anda tidak ingin berubah, tidak. Karena keduanya adalah HerbivoreEaters yang ditentukan, setidaknya sampai Anda menambahkan Cheetah Anda juga telah menguji bahwa semua HerbivoreEaters akan Makan sebagaimana dimaksud. Pengujian Anda sepenuhnya mencakup dan melatih kode secara memadai (asalkan Anda juga membuat semua pernyataan yang diharapkan tentang apa yang dihasilkan dari HerbivoreEater yang memakan Herbivore).

KeithS
sumber