Понятно, что необходимость написать код, который пройдет тестирование, — дисциплинирует и помогает грамотнее планировать архитектуру. Тем не менее, первая мысль о покрытии юнит-тестами асинхронного кода на Boost.Asio была примерно такая: «Что?! Это абсолютно невозможно! Как можно написать тест, основанный на сетевой доступности узла?»
Потом появилась идея каким-то образом эмулировать удаленный узел и его ответы на команды, полученные от нашего пингера. При дальнейшем изучении реализации асинхронных примитивов из Boost.Asio возникла мысль о параметризации готовых примитивов тестовыми реализациями сервисов, которые и будут отвечать на наши команды.
Вот так выглядит упрощенная диаграмма сокета в Boost.Asio. Для простоты мы будем рассматривать только методы соединения, отправки и получения данных.
В коде библиотеки реализация этой схемы выглядит следующим образом:
template <typename Protocol, typename StreamSocketService = stream_socket_service<Protocol> > class basic_stream_socket : public basic_socket<Protocol, StreamSocketService> { }
При этом все вызовы в boost::asio::basic_stream_socket делегируются классу StreamSocketService. Вот часть кода библиотеки Boost.Asio, которая это наглядно демонстрирует:
template <typename ConnectHandler> void async_connect(const endpoint_type& peer_endpoint, BOOST_ASIO_MOVE_ARG(ConnectHandler) handler) { ..... this->get_service().async_connect(this->get_implementation(), peer_endpoint, BOOST_ASIO_MOVE_CAST(ConnectHandler)(handler)); }
Другим словами, сам класс сокета является, по сути, просто оберткой, которая параметризуется типами протокола и сервиса; наглядный пример статического полиморфизма. Итак, чтобы «подменить» реализацию методов сокета, нам необходимо задать в качестве параметра шаблона сокета нашу реализацию сервиса. Вот как выглядела бы эта иерархия сокета при использовании динамического полиморфизма с добавлением тестового сервиса.
В нашем случае, представляющем собой не что иное, как Compile time dependency injection, упрощенная диаграмма для тестового сокета будет выглядеть так.
В коде тестовые и рабочие примитивы описаны следующим образом.
class BoostPrimitives { public: typedef boost::asio::ip::tcp::socket TCPSocket; typedef boost::asio::ip::icmp::socket ICMPSocket; typedef boost::asio::ip::tcp::resolver Resolver; typedef boost::asio::deadline_timer Timer; };
class Primitives { public: typedef ba::basic_stream_socket < ba::ip::tcp, SocketService<ba::ip::tcp> > TCPSocket; typedef ba::basic_raw_socket < ba::ip::icmp, SocketService<ba::ip::icmp> > ICMPSocket; typedef ba::basic_deadline_timer < boost::posix_time::ptime, ba::time_traits<boost::posix_time::ptime>, TimerService < boost::posix_time::ptime, ba::time_traits<boost::posix_time::ptime> > > Timer; typedef ba::ip::basic_resolver < ba::ip::tcp, ResolverService<ba::ip::tcp> > Resolver; };
SocketService, TimerService и ResolverService — реализации тестовых сервисов.
Примитивы таймера и резолвера имен, а также их сервисы имеют схожую структуру, поэтому мы ограничимся описанием сокетов и их сервисов.
А вот так в упрощенном виде будут представлены рабочая и тестовая реализации пингера.
В коде это выглядит следующим образом.
template<typename Traits> class PingerImpl { ..... //! Socket type typedef typename Traits::TCPSocket TCPSocket; ..... }
class Pinger { //! Implementation type typedef PingerImpl<BoostPrimitives> Impl; .... private: //! Implementation std::auto_ptr<Impl> m_Impl; };
class BaseTest : boost::noncopyable { protected: //! Pinger implementation type typedef Net::PingerImpl<Test::Primitives> TestPinger; .... };
Итак, у нас есть доступ к отдельным операциям примитивов. Теперь нужно понять, как с их помощью организовать тестовый случай, покрывающий процесс пинга. Мы можем представить этот процесс (пинг узла) как последовательность команд, выполняемых посредством библиотеки Boost.Asio. Нам необходима некая очередь команд, которая будет заполняться в процессе инициализации тестового сценария и опустошаться в процессе выполнения пинга. Вот диаграмма состояний, описывающая работу тестов.
Введем абстракцию ICommand, которая будет предоставлять методы, аналогичные методам примитивов Boost.Asio, и создадим классы — реализации конкретных команд (класс Connect будет реализовывать соединения с узлом, класс Receive — получение данных и т. д.).
UML-диаграмма работы тестов представлена ниже.
//! Pinger test command interface class ICommand : boost::noncopyable { public: //! Command pointer typedef boost::shared_ptr<ICommand> Ptr; //! Error callback type typedef boost::function<void(const boost::system::error_code&)> ErrorCallback; //! Error and size callback typedef boost::function<void(const boost::system::error_code&, std::size_t)> ErrorAndSizeCallback; //! Resolver callback typedef boost::function<void(const boost::system::error_code&, boost::asio::ip::tcp::resolver::iterator)> ResolverCallback; public: ICommand(const Status::Enum status) : m_Status(status) {} //! Timer wait virtual void AsyncWait(ErrorCallback& callback, boost::asio::io_service& io); //! Async connect virtual void AsyncConnect(ErrorCallback& callback, boost::asio::io_service& io); //! Async receive virtual void AsyncReceive(ErrorAndSizeCallback& callback, const std::vector<char>& sended, const boost::asio::mutable_buffer& buffer, boost::asio::io_service& io); //! Async resolve virtual void AsyncResolve(ResolverCallback& callback, boost::asio::io_service& io); //! Dtor virtual ~ICommand() {} protected: Status::Enum m_Status; };
При этом методы, не предоставляемые конкретной командой, будут содержать тестовые утверждения: таким образом мы сможем контролировать последовательность выполнения команд.
void Connect::AsyncConnect(ErrorCallback& callback, boost::asio::io_service& io) { if (m_Status != Status::Pending) { io.post(boost::bind(callback, m_Code)); callback = ErrorCallback(); } }
Реализация «по умолчанию» сообщает о том, что команда извлечена не в свою очередь:
void ICommand::AsyncConnect(ErrorCallback& /*callback*/, boost::asio::io_service& /*io*/) { assert(false); }
Также нам потребуется класс — тестовый случай, предоставляющий методы работы с очередью команд и проверяющий, что после выполнения теста в очереди не осталось команд.
//! Test fixture class Fixture { //! Commands list typedef std::list<ICommand::Ptr> Commands; public: Fixture(); ~Fixture(); static void Push(ICommand* cmd); static ICommand::Ptr Pop(); private: static Commands s_Commands; }; Fixture::Commands Fixture::s_Commands; Fixture::Fixture() { assert(s_Commands.empty()); // убеждаемся, что не осталось команд от предыдущего тест-кейса } Fixture::~Fixture() { assert(s_Commands.empty()); // все команды извлечены из очереди } void Fixture::Push(ICommand* cmd) { s_Commands.push_back(ICommand::Ptr(cmd)); } ICommand::Ptr Fixture::Pop() { assert(!s_Commands.empty()); const ICommand::Ptr result = s_Commands.front(); s_Commands.pop_front(); return result; }
template<typename T> void async_connect(implementation_type& /*impl*/, const endpoint& /*ep*/, const T& callback) { m_ConnectCallback = callback; Fixture::Pop()->AsyncConnect(m_ConnectCallback, m_Service); // извлекаем команду }
Юнит-тесты написаны на фреймворке Google, вот пример реализации теста для ICMP-пинга:
class BaseTest : boost::noncopyable { protected: //! Pinger implementation type typedef Net::PingerImpl<Test::Primitives> TestPinger; BaseTest() { m_Pinger.reset(new TestPinger(boost::bind(&BaseTest::Callback, this, _1, _2))); } virtual ~BaseTest() { m_Pinger->AddRequest(m_Command); while (m_Pinger->IsActive()) boost::this_thread::interruptible_wait(100); } template<typename T> void Cmd(const Status::Enum status) { m_Fixture.Push(new T(status)); } template<typename T, typename A> void Cmd(const Status::Enum status, const A& arg) { m_Fixture.Push(new T(status, arg)); } void Callback(const Net::PingCommand& /*cmd*/, const Net::PingResult& /*rslt*/) { // результат пинга нам не важен, мы проверяем сам процесс } Fixture m_Fixture; std::auto_ptr<TestPinger> m_Pinger; Net::PingCommand m_Command; }; // Параметризованные тесты для простоты понимания заменены обычными // При создании юнит-теста описываем последовательность команд, которые должны будут выполниться. class ICMPTest : public testing::Test, public BaseTest { }; TEST(ICMPTest, ICMPSuccess) { m_Command.m_HostName = "ptsecurity.ru"; Cmd<Resolve>(Status::Success, m_Command.m_HostName); // получаем IP по имени Cmd<Wait>(Status::Pending); // взводим таймер таймаута, передав Status::Pending – говорим, что он не должен сработать Cmd<Receive>(Status::Success); // узел прислал пакет с данными в ответ m_Command.m_Flags = SCANMGR_PING_ICMP; // выполнение команд пингером происходит в деструкторе класса BaseTest } TEST(ICMPTest, ICMPFail) { m_Command.m_HostName = "ptsecurity.ru"; Cmd<Resolve>(Status::Success, m_Command.m_HostName); // получаем IP по имени Cmd<Wait>(Status::Success); // взводим таймер таймаута, передав Status::Success – говорим, что он должен сработать Cmd<Receive>(Status::Pending); // ждем получения данных от узла m_Command.m_Flags = SCANMGR_PING_ICMP; // выполнение команд пингером происходит в деструкторе класса BaseTest }
Итак, с тестированием сетевой части пингера все понятно: нужно лишь описать последовательности команд для каждого из возможных сценариев пинга. Напомним, что логика пингера содержит несколько виртуальных методов, переопределяемых в классе PingerImpl. Таким образом, нам удалось отвязать логику от сетевой части.
На диаграмме класс TestLogic создан с помощью google mock. При этом в тестах логики определяется последовательность методов и аргументов, с которым они будут вызваны, при определенных входных параметрах.
class TestLogic : public Net::PingerLogic { public: TestLogic(const Net::PingCommand& cmd, const Net::Pinger::Callback& callback) : Net::PingerLogic(cmd, callback) { } MOCK_METHOD1(InitPorts, void (const std::string& ports)); MOCK_METHOD1(ResolveIP, bool (const std::string& name)); MOCK_METHOD1(StartResolveNameByIp, void (unsigned long ip)); MOCK_METHOD1(StartResolveIpByName, void (const std::string& name)); MOCK_METHOD1(StartTCPPing, void (std::size_t timeout)); MOCK_METHOD1(StartICMPPing, void (std::size_t timeout)); MOCK_METHOD1(StartGetNetBiosName, void (const std::string& name)); MOCK_METHOD0(Cancel, void ()); };
TEST(Logic, Start) { const std::string host = "ptsecurity.ru"; EXPECT_CALL(*m_Logic, InitPorts(g_TargetPorts)).Times(Exactly(1)); EXPECT_CALL(*m_Logic, ResolveIP(host)).Times(Exactly(1)).WillOnce(Return(true)); EXPECT_CALL(*m_Logic, StartResolveIpByName(host)).Times(Exactly(1)); m_Logic->OnStart(); } TEST(Logic, ResolveIp) { static const unsigned long ip = 0x10101010; EXPECT_CALL(*m_Logic, StartResolveNameByIp(ip)).Times(Exactly(1)); EXPECT_CALL(*m_Logic, StartICMPPing(1)).Times(Exactly(1)); EXPECT_CALL(*m_Logic, StartTCPPing(1)).Times(Exactly(1)); m_Logic->OnIpResolved(ip); }
В итоге поставленную задачу удалось успешно решить, благо Boost.Asio — отличный фреймворк, прекрасно подходящий для подобных целей. Кроме того, как водится, в процессе покрытия юнит-тестами было выявлено несколько серьезных багов 🙂 Само собой, нам удалось и сэкономить много часов ручного тестирования и отладки кода. С момента внедрения кода пингера в продукт, в нем был выявлен всего один мелкий баг, связанный с невнимательностью при написании кода, а значит, время на разработку и написание юнит-тестов потрачено не зря!
Отсюда можно сделать выводы:
- Модульное тестирование очень полезная вещь, юнит-тестами в идеале должен быть покрыт весь код.
- Практически любая задача покрытия кода тестами решаема! Нужно лишь разбить тестируемый код на достаточное количество абстракций.
Всем спасибо за внимание!
ссылка на оригинал статьи http://habrahabr.ru/company/pt/blog/166139/
Добавить комментарий