Apa pendekatan yang benar untuk menguji kelas dengan warisan?


8

Dengan asumsi saya memiliki struktur kelas berikut (terlalu disederhanakan):

class Base
{
  public:
    Base(int valueForFoo) : foo(valueForFoo) { };
    virtual ~Base() = 0;
    int doThings() { return foo; };
    int doOtherThings() { return 42; };

  protected:
    int foo;
}

class BarDerived : public Base
{
  public:
    BarDerived() : Base(12) { };
    ~BarDerived() { };
    int doBarThings() { return foo + 1; };
}

class BazDerived : public Base
{
  public:
    BazDerived() : Base(25) { };
    ~BazDerived() { };
    int doBazThings() { return 2 * foo; };
}

Seperti yang Anda lihat, doThingsfungsi di kelas Basis mengembalikan hasil yang berbeda di setiap kelas yang diturunkan karena nilai yang berbeda dari foo, sementara doOtherThingsfungsi berperilaku identik di semua kelas .

Ketika saya ingin mengimplementasikan unit test untuk kelas-kelas ini, penanganan doThings, doBarThings/ doBazThingsjelas bagi saya - mereka perlu dibahas untuk setiap kelas turunan. Tetapi bagaimana seharusnya doOtherThingsditangani? Apakah praktik yang baik pada dasarnya menduplikasi kasus uji di kedua kelas turunan? Masalahnya menjadi lebih buruk jika ada setengah lusin fungsi suka doOtherThings, dan lebih banyak turunannya kelas .


Apakah Anda yakin seharusnya hanya sang dtor virtual?
Deduplicator

@Duplikator Untuk kesederhanaan contoh, ya. Kelas Base adalah / harus abstrak, dengan kelas turunannya menyediakan fungsionalitas tambahan atau implementasi khusus. BarDeriveddan Basemungkin pernah menjadi kelas yang sama. Ketika fungsi serupa ditambahkan, bagian umum dipindahkan ke kelas Base, dengan spesialisasi yang berbeda diimplementasikan pada setiap kelas turunan.
CharonX

Dalam contoh yang kurang abstrak, bayangkan sebuah kelas yang menulis HTML sesuai standar, tetapi kemudian diputuskan bahwa mungkin juga menulis HTML dioptimalkan untuk <Browser> selain implementasi "vanilla" (yang melakukan beberapa hal secara berbeda, dan tidak tidak mendukung semua fungsi yang disediakan oleh standar). (Catatan: Saya lega mengatakan bahwa kelas nyata yang mengarah ke pertanyaan ini tidak ada hubungannya dengan menulis "browser-dioptimalkan" HTML)
CharonX

Jawaban:


3

Dalam pengujian BarDerivedAnda, Anda ingin membuktikan bahwa semua metode (publik) BarDerivedbekerja dengan benar (untuk situasi yang telah Anda uji). Demikian pula untuk BazDerived.

Fakta bahwa beberapa metode diimplementasikan dalam kelas dasar tidak mengubah tujuan pengujian ini untuk BarDeriveddan BazDerived. Itu mengarah pada kesimpulan yang Base::doOtherThingsharus diuji baik dalam konteks BarDeriveddan BazDeriveddan bahwa Anda mendapatkan tes yang sangat mirip untuk fungsi itu.

Keuntungan dari pengujian doOtherThingsuntuk setiap kelas turunan adalah bahwa jika persyaratan untuk BarDerivedperubahan seperti itu BarDerived::doOtherThingsharus kembali 24, maka kegagalan tes dalam BazDerivedtestcase memberi tahu Anda bahwa Anda mungkin melanggar persyaratan dari kelas lain.


2
Dan tidak ada yang menghentikan Anda dari mengurangi duplikasi dengan memfaktorkan kode uji umum menjadi fungsi tunggal yang disebut dari kedua kasus uji terpisah.
Sean Burton

1
IMHO seluruh jawaban ini hanya akan menjadi yang baik dengan menambahkan dengan jelas komentar @ SeanBurton, kalau tidak, itu tampak seperti pelanggaran terang-terangan prinsip KERING bagiku.
Doc Brown

3

Tetapi bagaimana seharusnya hal-hal lain ditangani? Apakah praktik yang baik pada dasarnya menduplikasi kasus uji di kedua kelas turunan?

Saya biasanya mengharapkan Base memiliki spesifikasi sendiri, yang dapat Anda verifikasi untuk implementasi yang sesuai, termasuk kelas turunannya.

void verifyBaseCompliance(const Base & systemUnderTest) {
    // checks that systemUnderTest conforms to the Base API
    // specification
}

void testBase () { verifyBaseCompliance(new Base()); }
void testBar () { verifyBaseCompliance(new BarDerived()); }
void testBaz () { verifyBaseCompliance(new BazDerived()); }

1

Anda memiliki konflik di sini.

Anda ingin menguji nilai kembali dari doThings(), yang bergantung pada literal (nilai const).

Setiap tes yang Anda tulis untuk ini secara inheren akan mendidih menguji nilai const , yang tidak masuk akal.


Untuk menunjukkan kepada Anda contoh yang lebih masuk akal (saya lebih cepat dengan C #, tetapi prinsipnya sama)

public class TriplesYourInput : Base
{
    public TriplesYourInput(int input)
    {
        this.foo = 3 * input;
    }
}

Kelas ini dapat diuji secara bermakna:

var inputValue = 123;

var expectedOutputValue = inputValue * 3;
var receivedOutputValue = new TriplesYourInput(inputValue).doThings();

Assert.AreEqual(receivedOutputValue, expectedOutputValue);

Ini lebih masuk akal untuk diuji. Keluarannya didasarkan pada input yang Anda pilih untuk diberikan. Dalam kasus seperti itu, Anda dapat memberikan kelas input yang dipilih secara sewenang-wenang, mengamati hasilnya, dan menguji apakah kelas itu sesuai dengan harapan Anda.

Beberapa contoh prinsip pengujian ini. Perhatikan bahwa contoh saya selalu memiliki kendali langsung atas apa input dari metode yang dapat diuji.

  • Tes jika GetFirstLetterOfString()mengembalikan "F" ketika saya memasukkan "Flater".
  • Tes jika CountLettersInString()mengembalikan 6 ketika saya memasukkan "Flater".
  • Tes jika ParseStringThatBeginsWithAnA()mengembalikan pengecualian ketika saya memasukkan "Flater".

Semua tes ini dapat memasukkan nilai apa pun yang mereka inginkan , selama harapan mereka sesuai dengan apa yang mereka masukkan.

Tetapi jika output Anda ditentukan oleh nilai konstan, maka Anda harus membuat ekspektasi konstan, dan kemudian menguji apakah yang pertama cocok dengan yang kedua. Yang konyol, ini selalu atau tidak akan pernah terjadi; tak satu pun dari keduanya merupakan hasil yang bermakna.

Beberapa contoh prinsip pengujian ini. Perhatikan bahwa contoh-contoh ini tidak memiliki kendali atas setidaknya satu dari nilai-nilai yang sedang dibandingkan.

  • Tes jika Math.Pi == 3.1415...
  • Tes jika MyApplication.ThisConstValue == 123

Tes-tes ini untuk satu nilai tertentu. Jika Anda mengubah nilai ini, tes Anda akan gagal. Intinya, Anda tidak menguji apakah logika Anda berfungsi untuk input yang valid, Anda hanya menguji apakah seseorang dapat memprediksi hasil yang secara akurat tidak dapat mereka kendalikan.

Itu pada dasarnya menguji pengetahuan penulis tes tentang logika bisnis. Itu tidak menguji kode, tetapi penulis sendiri.


Mengembalikan ke contoh Anda:

class BarDerived : public Base
{
  public:
    BarDerived() : Base(12) { };
    ~BarDerived() { };
    int doBarThings() { return foo + 1; };
}

Kenapa BarDerivedharus selalu foosama dengan 12? Apa artinya ini?

Dan mengingat bahwa Anda sudah memutuskan ini, apa yang Anda coba dapatkan dengan menulis tes yang menegaskan bahwa BarDerivedselalu foosama dengan 12?

Ini menjadi lebih buruk jika Anda mulai memfaktorkan yang doThings()dapat ditimpa dalam kelas turunan. Bayangkan jika AnotherDerivedditimpa doThings()sehingga selalu kembali foo * 2. Sekarang, Anda akan memiliki kelas yang hardcoded sebagai Base(12), yang doThings()nilainya 24. Meskipun secara teknis dapat diuji, itu tidak memiliki makna kontekstual. Tes ini tidak dapat dipahami.

Saya benar-benar tidak bisa memikirkan alasan untuk menggunakan pendekatan nilai hardcoded ini. Bahkan jika ada use case yang valid, saya tidak mengerti mengapa Anda mencoba menulis tes untuk mengonfirmasi nilai hardcoded ini . Tidak ada untungnya dengan menguji jika nilai konstan sama dengan nilai konstan yang sama.

Kegagalan tes apa pun secara inheren membuktikan bahwa tes itu salah . Tidak ada hasil di mana kegagalan pengujian membuktikan bahwa logika bisnis salah. Anda secara efektif tidak dapat mengkonfirmasi tes mana yang dibuat untuk mengonfirmasi di tempat pertama.

Masalahnya tidak ada hubungannya dengan warisan, jika Anda bertanya-tanya. Anda kebetulan menggunakan nilai const di konstruktor kelas dasar, tetapi Anda bisa menggunakan nilai const ini di tempat lain dan kemudian itu tidak akan terkait dengan kelas yang diwarisi.


Edit

Ada kasus di mana nilai hardcoded tidak menjadi masalah. (sekali lagi, maaf untuk sintaks C # tetapi prinsipnya masih sama)

public class Base
{
    public int MultiplyFactor;
    protected int InitialValue;

    public Base(int value, int factor)
    {
        this.InitialValue = value;
        this.MultiplyFactor= factor;
    }

    public int GetMultipliedValue()
    {
         return this.InitialValue * this.MultiplyFactor;
    }
}

public class DoublesYourNumber : Base
{
    public DoublesYourNumber(int value) :  base(value, 2) {}
}

public class TriplesYourNumber : Base
{
    public TriplesYourNumber(int value) : base(value, 3) {}
}

Sementara nilai konstan ( 2/ 3) masih mempengaruhi nilai output GetMultipliedValue(), konsumen kelas Anda masih memiliki kendali atas itu juga!
Dalam contoh ini, tes yang berarti masih dapat ditulis:

var inputValue = 123;

var expectedDoubledOutputValue = inputValue * 2;
var receivedDoubledOutputValue = new DoublesYourNumber(inputValue).GetMultipliedValue();

Assert.AreEqual(expectedDoubledOutputValue , receivedDoubledOutputValue);

var expectedTripledOutputValue = inputValue * 3;
var receivedTripledOutputValue = new TriplesYourNumber(inputValue).GetMultipliedValue();

Assert.AreEqual(expectedTripledOutputValue , receivedTripledOutputValue);
  • Secara teknis, kami masih menulis tes yang memeriksa apakah const in base(value, 2)cocok dengan const in inputValue * 2.
  • Namun, kami pada saat yang sama juga menguji bahwa kelas ini dengan benar mengalikan nilai yang diberikan oleh faktor yang telah ditentukan ini .

Poin pertama tidak relevan untuk diuji. Yang kedua adalah!


Seperti yang telah disebutkan, ini adalah struktur kelas yang agak disederhanakan. Yang mengatakan, izinkan saya merujuk pada contoh yang mungkin kurang abstrak: Bayangkan kelas menulis HTML. Kita semua tahu bahwa standar <dan >kawat gigi yang merangkum tag HTML. Sayangnya (karena kegilaan) browser "khusus" menggunakan ![{dan }]!sebagai gantinya, dan Intitech memutuskan bahwa Anda perlu mendukung browser ini di penulis HTML Anda. Misalnya Anda memiliki getHeaderStart()dan getHeaderEnd()fungsi yang - sampai sekarang - dikembalikan <HEADER>dan <\HEADER>.
CharonX

@CharonX Anda masih perlu membuat jenis tag (apakah melalui enum, atau dua properti string untuk tag yang digunakan, atau apa pun yang setara) dapat dikonfigurasi secara publik untuk menguji secara bermakna apakah kelas dengan benar menggunakan tag. Jika tidak, tes Anda akan dikotori dengan nilai-nilai konstan tidak berdokumen yang diperlukan untuk mendapatkan tes untuk bekerja.
Flater

Anda dapat mengubah kelas dan fungsi hanya dengan menyalin-menempelkan semuanya - sekali dengan <, yang lain dengan ![{. Tapi itu agak buruk. Jadi Anda memiliki fungsi masing-masing insert karakter (s) ditetapkan dalam variabel di tempat <dan >akan pergi, dan membuat kelas turunan yang - tergantung jika mereka standar sesuai atau gila, memberikan keperluan ini, <HEADER>atau ![{HEADER}]!Baik getHeaderStart()fungsi tergantung pada input dan bergantung pada set konstan selama konstruksi kelas turunan. Namun, saya akan merasa tidak enak jika Anda mengatakan kepada saya bahwa mengujinya tidak masuk akal ...
CharonX

@CharonX Bukan itu intinya. Intinya adalah bahwa output Anda (yang jelas memutuskan jika tes lulus) didasarkan pada nilai yang tes itu sendiri tidak memiliki kendali atas. Jika tidak, Anda harus menguji nilai output yang tidak bergantung pada const tersembunyi ini. Contoh kode Anda sedang menguji nilai const ini, bukan yang lain. Itu salah, atau terlalu disederhanakan sampai tidak menunjukkan tujuan sebenarnya dari output.
Flater

Apakah pengujian getHeaderStart()masuk akal atau tidak?
CharonX
Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.