Тестирование закрытого члена класса в C++ без друга

Сегодня у меня была дискуссия с коллегой о том, проверять или не проверять частных членов или частное государство в классе. Он почти убедил меня, почему это имеет смысл. Этот вопрос не направлен на дублирование уже существующих вопросов Stackru о природе и причине тестирования закрытых членов, например: Что плохого в том, чтобы сделать модульное тестирование другом класса, который он тестирует?

Предложение коллег было, на мой взгляд, немного хрупким, чтобы ввести объявление друга в класс реализации модульного теста. По моему мнению, это не нужно, потому что мы вводим некоторую зависимость тестируемого кода в тестовый код, тогда как тестовый код уже зависит от тестируемого кода => циклическая зависимость. Даже такие невинные вещи, как переименование тестового класса, приводят к нарушению модульных тестов и приводят к изменениям кода в тестируемом коде.

Я хотел бы попросить гуру C++ судить о другом предложении, которое опирается на тот факт, что нам разрешено специализировать шаблонную функцию. Просто представьте себе класс:

// tested_class.h

struct tested_class 
{
  tested_class(int i) : i_(i) {}

  //some function which do complex things with i
  // and sometimes return a result

private:
  int i_;
};

Мне не нравится идея иметь геттер для i_ просто чтобы сделать его тестируемым. Итак, мое предложение - объявление шаблона функции 'test_backdoor' в классе:

// tested_class.h

struct tested_class 
{
  explicit
  tested_class(int i=0) : i_(i) {}

  template<class Ctx>
  static void test_backdoor(Ctx& ctx);

  //some function which do complex things with i
  // and sometimes return a result

private:
  int i_;
};

Добавляя только эту функцию, мы можем сделать закрытые члены класса тестируемыми. Обратите внимание, что нет никакой зависимости ни от классов модульного теста, ни от реализации функции шаблона. В этом примере реализация модульного теста использует платформу Boost Test.

// tested_class_test.cpp

namespace
{
  struct ctor_test_context
  {
    tested_class& tc_;
    int expected_i;
  };
}

// specialize the template member to do the rest of the test
template<>
void tested_class::test_backdoor<ctor_test_context>(ctor_test_context& ctx)
{
  BOOST_REQUIRE_EQUAL(ctx.expected_i, tc_.i_);
}

BOOST_AUTO_TEST_CASE(tested_class_default_ctor)
{
  tested_class tc;
  ctor_test_context ctx = { tc, 0 };
  tested_class::test_backdoor(ctx);
}

BOOST_AUTO_TEST_CASE(tested_class_value_init_ctor)
{
  tested_class tc(-5);
  ctor_test_context ctx = { tc, -5 };
  tested_class::test_backdoor(ctx);
}

Вводя только одно объявление шаблона, которое вообще не вызывается, мы даем разработчику теста возможность пересылать тестовую логику в функцию. Функция действует в безопасных для типов контекстах и ​​видима только изнутри конкретного модуля компиляции теста из-за анонимной природы типа тестового контекста. И самое главное, мы можем определить столько анонимных тестовых контекстов, сколько нам нравится, и специализировать тесты на них, даже не затрагивая тестируемый класс.

Конечно, пользователи должны знать, что такое специализация шаблонов, но действительно ли этот код плохой или странный или нечитаемый? Или я могу ожидать от разработчиков C++ знания, что такое специализация шаблонов C++ и как она работает?

Разрабатывая использование друга для объявления класса модульного теста, я не думаю, что это надежно. Представьте себе фреймворк для повышения (или другие тестовые фреймворки). Он генерирует для каждого теста отдельный тип. Но почему меня должно волновать, как долго я могу написать:

BOOST_AUTO_TEST_CASE(tested_class_value_init_ctor)
{
  ...
}

Если вы используете друзей, я должен был объявить каждый тестовый пример как друга, а затем... Или в конечном итоге ввести некоторые тестовые функции в каком-то общем типе (например, в приспособлении), объявить его как друга и перенаправить все тестовые вызовы этому типу. Разве это не странно?

Я хотел бы видеть, что ваши плюсы и минусы практикуют этот подход.

9 ответов

Решение

То, что последует, не является технически прямым ответом на ваш вопрос, поскольку он все еще использует функциональность "друга", но не требует модификации самого тестируемого объекта, и я думаю, что это добавляет беспокойство о нарушении инкапсуляции, упомянутой в некоторых из других ответов; хотя это требует написания некоторого стандартного кода.

Идея, лежащая в его основе, не моя, и реализация полностью основана на трюке, представленном и объясненном litb в его блоге(в сочетании с этим подходом Саттера для чуть большего контекста, по крайней мере для меня) - короче говоря, CRTP, друзья, ADL и указатели на член (я должен признаться, что к моему ужасу АДЛ части я до сих пор не получить его полностью, но я relentesly работаю в выяснении его 100%).

Я протестировал его с компиляторами gcc 4.6, clang 3.1 и VS2010, и он отлично работает.

/* test_tag.h */
#ifndef TEST_TAG_H_INCLUDED_
#define TEST_TAG_H_INCLUDED_

template <typename Tag, typename Tag::type M>
struct Rob
{
    friend typename Tag::type get(Tag)
    {
        return M;
    }
};

template <typename Tag, typename Member> 
struct TagBase
{
    typedef Member type;
    friend type get(Tag);
};


#endif /* TEST_TAG_H_INCLUDED_ */

/* tested_class.h */
#ifndef TESTED_CLASS_H_INCLUDED_
#define TESTED_CLASS_H_INCLUDED_

#include <string>

struct tested_class
{
    tested_class(int i, const char* descr) : i_(i), descr_(descr) { }

private:
    int i_;
    std::string descr_;
};

/* with or without the macros or even in a different file */
#   ifdef TESTING_ENABLED
#   include "test_tag.h"

    struct tested_class_i : TagBase<tested_class_i, int tested_class::*> { };
    struct tested_class_descr : TagBase<tested_class_descr, const std::string tested_class::*> { };

    template struct Rob<tested_class_i, &tested_class::i_>;
    template struct Rob<tested_class_descr, &tested_class::descr_>;

#   endif

#endif /* TESTED_CLASS_H_INCLUDED_ */

/* test_access.cpp */
#include "tested_class.h"

#include <cstdlib>
#include <iostream>
#include <sstream>

#define STRINGIZE0(text) #text
#define STRINGIZE(text) STRINGIZE0(text)

int assert_handler(const char* expr, const char* theFile, int theLine)
{
    std::stringstream message;
    message << "Assertion " << expr << " failed in " << theFile << " at line " << theLine;
    message << "." << std::endl;
    std::cerr << message.str();

    return 1;
}

#define ASSERT_HALT() exit(__LINE__)

#define ASSERT_EQUALS(lhs, rhs) ((void)(!((lhs) == (rhs)) && assert_handler(STRINGIZE((lhs == rhs)), __FILE__, __LINE__) && (ASSERT_HALT(), 1)))

int main()
{
    tested_class foo(35, "Some foo!");

    // the bind pointer to member by object reference could
    // be further wrapped in some "nice" macros
    std::cout << " Class guts: " << foo.*get(tested_class_i()) << " - " << foo.*get(tested_class_descr()) << std::endl;
    ASSERT_EQUALS(35, foo.*get(tested_class_i()));
    ASSERT_EQUALS("Some foo!", foo.*get(tested_class_descr()));

    ASSERT_EQUALS(80, foo.*get(tested_class_i()));

    return 0; 
}

Я думаю, что юнит-тестирование - это проверка наблюдаемого поведения тестируемого класса. Поэтому нет необходимости проверять закрытые детали, так как они сами не наблюдаемы. Вы проверяете это, проверяя, ведет ли себя объект так, как вы ожидаете (что подразумевает, что все частные внутренние состояния находятся в порядке).

Причина, по которой не стоит беспокоиться о закрытых частях, заключается в том, что таким образом вы можете изменить реализацию (например, рефакторинг) без необходимости переписывать свои тесты.

Поэтому мой ответ - не делайте этого (даже если это технически возможно), поскольку это противоречит философии модульных тестов.

Pros

  • Вы можете получить доступ к закрытым членам, чтобы проверить их
  • Это довольно минимальное количество hack

Cons

  • Сломанная инкапсуляция
  • Нарушенная инкапсуляция, которая является более сложной и такой же хрупкой, как friend
  • Смешивание теста с производственным кодом путем помещения test_backdoor на производственной стороне
  • Проблема с обслуживанием (точно так же, как при знакомстве с тестовым кодом, вы создали чрезвычайно тесную связь с тестовым кодом)

За исключением всех плюсов и минусов, я думаю, что вам лучше всего внести некоторые архитектурные изменения, которые позволят лучше тестировать любые сложные вещи.

Возможные решения

  • Используйте идиому Pimpl, поставьте complex код в pimpl вместе с приватным членом и написать тест для Pimpl. Pimpl может быть заранее объявлен как открытый член, что позволяет выполнять внешнюю реализацию в модульном тесте. Pimpl может состоять только из открытых участников, что облегчает тестирование
    • Недостаток: много кода
    • Недостаток: непрозрачный тип, который может быть труднее увидеть внутри при отладке
  • Просто протестируйте открытый / защищенный интерфейс класса. Протестируйте контракт, который выложил ваш интерфейс.
    • Недостаток: модульные тесты трудно / невозможно написать изолированным способом.
  • Аналогичен решениям Pimpl, но создает бесплатную функцию с complex код в нем. Поместите объявление в закрытый заголовок (не являющийся частью открытого интерфейса библиотек) и протестируйте его.
  • Разрушить инкапсуляцию через друга методом испытаний / приспособлением
    • Возможные варианты этого: объявить friend struct test_context;, поместите ваш тестовый код внутри методов в реализации struct test_context, Таким образом, вам не нужно дружить с каждым тестовым примером, методом или прибором. Это должно снизить вероятность того, что кто-то сломает фридайнг.
  • Разорвать инкапсуляцию через специализацию шаблона

Прошу прощения за этот совет, но он помог мне, когда большинство методов в этих ответах недостижимо без строгого рефакторинга: добавьте перед заголовком файл с классом, к закрытым членам которого вы хотите получить доступ,

#define private public

Это зло, но

  • не мешает производственному коду

  • не нарушает инкапсуляцию, как это делает друг / изменение уровня доступа

  • избегает интенсивного рефакторинга с помощью PIMPL

так что вы можете пойти на это...

Я думаю, что первое, что нужно спросить: почему друг считается тем, что следует использовать с осторожностью?

Потому что это нарушает инкапсуляцию. Он предоставляет другому классу или функции доступ к внутренним объектам вашего объекта, расширяя тем самым видимую область ваших личных членов. Если у вас много друзей, гораздо сложнее рассуждать о состоянии вашего объекта.

На мой взгляд, шаблонное решение в этом отношении даже хуже, чем у друга. Ваше основное заявленное преимущество шаблона состоит в том, что вам больше не нужно явно дружить с тестом из класса. Я утверждаю, что, наоборот, это ущерб. Для этого есть две причины.

  1. Тест связан с внутренностями вашего класса. Любой, кто меняет класс, должен знать, что, изменяя ряды объекта, он может нарушить тест. friend сообщает им, какие объекты могут быть связаны с внутренним состоянием вашего класса, а шаблонное решение - нет.

  2. Друг ограничивает сферу расширения ваших рядовых. Если вы дружите с классом, вы знаете, что только этот класс может получить доступ к вашим внутренним элементам. Таким образом, если вы знакомы с тестом, вы знаете, что только тест может читать или записывать частные переменные-члены. Ваш задний ход шаблона, однако, может использоваться где угодно.

Шаблонное решение неэффективно, потому что оно скрывает проблему, а не исправляет ее. Основная проблема с циклической зависимостью все еще существует: кто-то, изменяющий класс, должен знать о каждом использовании черного хода, а кто-то, изменяющий тест, должен знать о классе. В основном, ссылка на тест из класса была удалена только путем превращения всех личных данных в общедоступные данные окольным путем.

Если вы хотите получить доступ к закрытым членам вашего теста, просто подружитесь с тестовым прибором и покончите с ним. Это просто и понятно.

Обычно я не чувствую необходимости в модульном тестировании частных членов и функций. Я мог бы предпочесть ввести публичную функцию только для проверки правильности внутреннего состояния.

Но если я решу покопаться в деталях, я использую неприятный быстрый взлом в программе модульного тестирования:

#include <system-header>
#include <system-header>
// Include ALL system headers that test-class-header might include.
// Since this is an invasive unit test that is fiddling with internal detail
// that it probably should not, this is not a hardship.

#define private public
#include "test-class-header.hpp"
...

В Linux по крайней мере это работает, потому что искажение имени в C++ не включает в себя состояние private/public. Мне говорят, что в других системах это не может быть правдой и не будет связывать.

Тестирование закрытых членов не всегда связано с проверкой состояния путем проверки, соответствует ли оно некоторым ожидаемым значениям. Чтобы приспособиться к другим, более сложным сценариям тестирования, я иногда использую следующий подход (упрощенный здесь, чтобы передать основную идею):

// Public header
struct IFoo
{
public:
    virtual ~IFoo() { }
    virtual void DoSomething() = 0;
};
std::shared_ptr<IFoo> CreateFoo();

// Private test header
struct IFooInternal : public IFoo
{
public:
    virtual ~IFooInternal() { }
    virtual void DoSomethingPrivate() = 0;
};

// Implementation header
class Foo : public IFooInternal
{
public:
    virtual DoSomething();
    virtual void DoSomethingPrivate();
};

// Test code
std::shared_ptr<IFooInternal> p =
    std::dynamic_pointer_cast<IFooInternal>(CreateFoo());
p->DoSomethingPrivate();

Этот подход имеет явное преимущество в продвижении хорошего дизайна и в том, что он не запутывается в объявлениях друзей. Конечно, вам не нужно преодолевать проблемы большую часть времени, потому что возможность тестировать частных пользователей - это довольно нестандартное требование с самого начала.

Я использовал функцию для проверки закрытых членов класса, которая была названа TestInvariant().

Он был закрытым членом класса и в режиме отладки вызывался в начале и в конце каждой функции (кроме начала ctor и конца dctor).

Он был виртуальным, и любой базовый класс назывался родительской версией, а не собственной.

Это позволило мне постоянно проверять внутреннее состояние класса, не раскрывая его намерений кому-либо. У меня были очень простые тесты, но нет никаких причин, по которым вы не могли бы проводить сложные тесты или даже включать или выключать их с помощью флага и т. Д.

Также у вас могут быть открытые функции Test, которые могут вызываться другими классами, которые вызывают вашу функцию TestInvariant(). Поэтому, когда вам нужно изменить работу внутреннего класса, вам не нужно менять какой-либо пользовательский код.

Это поможет?

Существует теория, что если это частное приложение, его не следует тестировать в одиночку, если это необходимо, его следует переработать.

Для меня это шиизм.

В некоторых проектах люди создают макрос для приватных методов, например:

class Something{
   PRIVATE:
       int m_attr;
};

При компиляции для теста PRIVATE определяется как открытый, в противном случае он определяется как закрытый. так просто.

Другие вопросы по тегам