Cách tiếp cận đúng để kiểm tra các lớp với sự kế thừa là gì?


8

Giả sử tôi có cấu trúc lớp (đơn giản hóa) sau:

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; };
}

Như bạn có thể thấy, doThingshàm trong lớp Cơ sở trả về các kết quả khác nhau trong mỗi lớp Derogen do các giá trị khác nhau foo, trong khi doOtherThingshàm hoạt động giống hệt nhau trên tất cả các lớp .

Khi tôi muốn thực hiện các bài kiểm tra đơn vị cho các lớp này, việc xử lý doThings, doBarThings/ doBazThingsrõ ràng đối với tôi - chúng cần được bảo hiểm cho mỗi lớp dẫn xuất. Nhưng nên doOtherThingsxử lý thế nào? Có phải thực tế tốt để sao chép cơ bản trường hợp thử nghiệm trong cả hai lớp dẫn xuất? Vấn đề trở nên tồi tệ hơn nếu có nửa tá chức năng như doOtherThingsvà nhiều lớp Derogen hơn .


Bạn có chắc chắn chỉ có dtor nên virtual?
Ded repeatator

@Ded repeatator Vì sự đơn giản của ví dụ, vâng. Lớp cơ sở là / nên trừu tượng, với các lớp dẫn xuất cung cấp chức năng bổ sung hoặc triển khai chuyên biệt. BarDerivedBasecó thể đã từng là cùng một lớp. Khi một chức năng tương tự được thêm vào, phần chung đã được chuyển vào lớp Cơ sở, với các chuyên môn khác nhau được triển khai trong mỗi lớp Derogen.
CharonX

Trong một ví dụ ít trừu tượng hơn, hãy tưởng tượng một lớp viết HTML tuân thủ tiêu chuẩn, nhưng sau đó người ta đã quyết định rằng cũng có thể viết HTML được tối ưu hóa cho <Browser> bên cạnh việc triển khai "vanilla" (có một vài điều khác biệt và thực hiện không hỗ trợ tất cả các chức năng được cung cấp bởi tiêu chuẩn). (Lưu ý: Tôi cảm thấy nhẹ nhõm khi nói rằng các lớp thực sự dẫn đến câu hỏi này không liên quan gì đến việc viết HTML "tối ưu hóa trình duyệt")
CharonX

Câu trả lời:


3

Trong các thử nghiệm của BarDerivedbạn, bạn muốn chứng minh rằng tất cả các phương pháp (công khai) BarDerivedhoạt động chính xác (đối với các tình huống bạn đã kiểm tra). Tương tự cho BazDerived.

Thực tế là một số phương thức được triển khai trong lớp cơ sở không thay đổi mục tiêu thử nghiệm này cho BarDerivedBazDerived. Điều đó dẫn đến kết luận Base::doOtherThingsnên được kiểm tra cả trong bối cảnh BarDerivedBazDerivedvà bạn có được các bài kiểm tra rất giống nhau cho chức năng đó.

Ưu điểm của kiểm thử doOtherThingsđối với mỗi lớp dẫn xuất là nếu các yêu cầu BarDerivedthay đổi BarDerived::doOtherThingsphải trả về 24, thì lỗi kiểm tra trong BazDerivedtestcase cho bạn biết rằng bạn có thể phá vỡ các yêu cầu của lớp khác.


2
Và không có gì ngăn bạn giảm trùng lặp bằng cách bao gồm mã kiểm tra chung thành một hàm duy nhất được gọi từ cả hai trường hợp kiểm thử riêng biệt.
Sean Burton

1
IMHO toàn bộ câu trả lời này sẽ chỉ trở thành một câu trả lời tốt bằng cách thêm rõ ràng bình luận của @ SeanBurton, nếu không, nó có vẻ như là một sự vi phạm trắng trợn nguyên tắc DRY đối với tôi.
Doc Brown

3

Nhưng nên xử lý các vấn đề khác như thế nào? Có phải thực tế tốt để sao chép cơ bản trường hợp thử nghiệm trong cả hai lớp dẫn xuất?

Tôi thường mong đợi Base có đặc tả riêng, mà bạn có thể xác minh cho bất kỳ triển khai tuân thủ nào, bao gồm các lớp dẫn xuất.

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

Bạn có một cuộc xung đột ở đây.

Bạn muốn kiểm tra giá trị trả về của doThings(), giá trị này dựa trên giá trị bằng chữ (const).

Bất kỳ bài kiểm tra nào bạn viết cho điều này vốn dĩ sẽ sôi sục khi kiểm tra một giá trị const , điều này là vô nghĩa.


Để cho bạn thấy một ví dụ hợp lý hơn (Tôi nhanh hơn với C #, nhưng nguyên tắc là như nhau)

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

Lớp này có thể được kiểm tra một cách có ý nghĩa:

var inputValue = 123;

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

Assert.AreEqual(receivedOutputValue, expectedOutputValue);

Điều này có ý nghĩa hơn để kiểm tra. Đầu ra của nó dựa trên đầu vào mà bạn đã chọn để cung cấp cho nó. Trong trường hợp như vậy, bạn có thể cung cấp cho một lớp một đầu vào được chọn tùy ý, quan sát đầu ra của nó và kiểm tra xem nó có phù hợp với mong đợi của bạn không.

Một số ví dụ về nguyên tắc thử nghiệm này. Lưu ý rằng các ví dụ của tôi luôn có quyền kiểm soát trực tiếp đối với đầu vào của phương thức có thể kiểm tra là gì.

  • Kiểm tra nếu GetFirstLetterOfString()trả về "F" khi tôi nhập "Flater".
  • Kiểm tra nếu CountLettersInString()trả về 6 khi tôi nhập "Flater".
  • Kiểm tra nếu ParseStringThatBeginsWithAnA()trả về một ngoại lệ khi tôi nhập "Flater".

Tất cả các thử nghiệm này có thể nhập bất kỳ giá trị nào họ muốn , miễn là kỳ vọng của họ phù hợp với những gì họ đang đưa vào.

Nhưng nếu đầu ra của bạn được quyết định bởi một giá trị không đổi, thì bạn sẽ phải tạo ra một kỳ vọng không đổi, sau đó kiểm tra xem đầu tiên có khớp với giá trị thứ hai không. Điều này thật ngớ ngẩn, điều này luôn luôn hoặc sẽ không bao giờ vượt qua; không phải là một kết quả có ý nghĩa

Một số ví dụ về nguyên tắc thử nghiệm này. Lưu ý rằng các ví dụ này không có quyền kiểm soát đối với ít nhất một trong các giá trị đang được so sánh.

  • Kiểm tra nếu Math.Pi == 3.1415...
  • Kiểm tra nếu MyApplication.ThisConstValue == 123

Những thử nghiệm cho một giá trị cụ thể. Nếu bạn thay đổi giá trị này, các bài kiểm tra của bạn sẽ thất bại. Về bản chất, bạn không kiểm tra xem logic của bạn có hoạt động cho bất kỳ đầu vào hợp lệ nào không, bạn chỉ đang kiểm tra xem ai đó có thể dự đoán chính xác kết quả mà họ không kiểm soát được hay không.

Điều đó về cơ bản là kiểm tra kiến ​​thức của người viết bài kiểm tra về logic kinh doanh. Đó không phải là kiểm tra mã, mà là chính người viết.


Trở lại ví dụ của bạn:

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

Tại sao BarDerivedluôn luôn có một foobằng 12? Ý nghĩa của việc này là gì?

Và cho rằng bạn đã quyết định điều này, bạn đang cố gắng đạt được điều gì bằng cách viết một bài kiểm tra xác nhận BarDerivedluôn luôn có giá trị foobằng 12?

Điều này thậm chí còn tồi tệ hơn nếu bạn bắt đầu bao thanh toán trong đó doThings()có thể bị ghi đè trong một lớp dẫn xuất. Hãy tưởng tượng nếu AnotherDerivedđược ghi đè doThings()để nó luôn trở lại foo * 2. Bây giờ, bạn sẽ có một lớp được mã hóa cứng Base(12), có doThings()giá trị là 24. Trong khi có thể kiểm tra về mặt kỹ thuật, nó không có bất kỳ ý nghĩa ngữ cảnh nào. Bài kiểm tra không thể hiểu được.

Tôi thực sự không thể nghĩ ra lý do để sử dụng phương pháp giá trị được mã hóa cứng này. Ngay cả khi có trường hợp sử dụng hợp lệ, tôi không hiểu tại sao bạn lại cố gắng viết bài kiểm tra để xác nhận giá trị được mã hóa cứng này . Không có gì đạt được bằng cách kiểm tra nếu một giá trị không đổi bằng cùng một giá trị không đổi.

Bất kỳ thử nghiệm thất bại vốn đã chứng minh rằng thử nghiệm là sai . Không có kết quả trong đó một thử nghiệm thất bại chứng minh rằng logic kinh doanh là sai. Bạn thực sự không thể xác nhận rằng thử nghiệm nào được tạo để xác nhận ngay từ đầu.

Vấn đề không liên quan gì đến thừa kế, trong trường hợp bạn đang tự hỏi. Bạn tình cờ đã sử dụng một giá trị const trong hàm tạo của lớp cơ sở, nhưng bạn có thể đã sử dụng giá trị const này ở bất kỳ nơi nào khác và sau đó nó sẽ không liên quan đến một lớp được kế thừa.


Biên tập

Có những trường hợp giá trị mã hóa cứng không phải là vấn đề. (một lần nữa, xin lỗi vì cú pháp C # nhưng nguyên tắc vẫn giống nhau)

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) {}
}

Trong khi giá trị không đổi ( 2/ 3) vẫn ảnh hưởng đến giá trị đầu ra GetMultipliedValue(), thì người tiêu dùng của lớp bạn cũng có quyền kiểm soát nó!
Trong ví dụ này, các bài kiểm tra có ý nghĩa vẫn có thể được viết:

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);
  • Về mặt kỹ thuật, chúng tôi vẫn đang viết một bài kiểm tra để kiểm tra xem const in có base(value, 2)khớp với const trong không inputValue * 2.
  • Tuy nhiên, chúng tôi đồng thời cũng kiểm tra rằng lớp này đang nhân chính xác bất kỳ giá trị đã cho nào với yếu tố được xác định trước này .

Điểm đạn đầu tiên không liên quan để kiểm tra. Thứ hai là!


Như đã đề cập, đây là một cấu trúc lớp khá đơn giản. Điều đó nói rằng, hãy để tôi tham khảo ví dụ có lẽ ít trừu tượng hơn: Hãy tưởng tượng một lớp viết HTML. Chúng ta đều biết rằng tiêu chuẩn <>dấu ngoặc nhọn đóng gói các thẻ HTML. Thật không may (do sự điên rồ) một trình duyệt "chuyên biệt" sử dụng ![{}]!thay vào đó, và Intitech quyết định rằng bạn cần hỗ trợ trình duyệt này trong trình soạn thảo HTML của mình. Ví dụ, bạn có chức năng getHeaderStart()getHeaderEnd()- cho đến bây giờ - đã trả lại <HEADER><\HEADER>.
CharonX

@CharonX Bạn vẫn cần tạo loại thẻ (cho dù thông qua enum hoặc hai thuộc tính chuỗi cho các thẻ đã sử dụng hoặc bất cứ thứ gì tương đương) có thể định cấu hình công khai để kiểm tra một cách có ý nghĩa nếu lớp sử dụng đúng các thẻ. Nếu bạn không làm như vậy, các bài kiểm tra của bạn sẽ bị vấy bẩn với các giá trị không đổi không có giấy tờ cần thiết để làm cho bài kiểm tra hoạt động.
Flater

Bạn có thể thay đổi lớp và các hàm bằng cách đơn giản là sao chép mọi thứ - một lần với <, khác với ![{. Nhưng điều đó sẽ khá tệ. Vì vậy, bạn có mỗi hàm chèn (các) ký tự được đặt trong một biến ở vị trí <>sẽ đi, và tạo các lớp dẫn xuất - tùy thuộc vào việc chúng tuân thủ tiêu chuẩn hay điên, cung cấp hàm thích hợp <HEADER>hoặc ![{HEADER}]!không getHeaderStart()phụ thuộc vào đầu vào và dựa vào trên tập không đổi trong quá trình xây dựng lớp dẫn xuất. Tuy nhiên, tôi sẽ cảm thấy không yên tâm nếu bạn nói với tôi rằng việc thử nghiệm chúng không có ý nghĩa gì ...
CharonX

@CharonX Đó không phải là vấn đề. Vấn đề là đầu ra của bạn (rõ ràng quyết định nếu thử nghiệm vượt qua) dựa trên một giá trị mà bản thân thử nghiệm không có quyền kiểm soát. Mặt khác, bạn nên kiểm tra một giá trị đầu ra không dựa vào const ẩn này. Mã ví dụ của bạn đang kiểm tra giá trị const này, không phải bất cứ điều gì khác. Điều đó là không chính xác, hoặc quá đơn giản đến mức không thể hiện mục đích thực tế của đầu ra.
Flater

Liệu thử nghiệm getHeaderStart()có ý nghĩa hay không?
CharonX
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.