Пингер на Boost.Asio и модульное тестирование

от автора

Всем привет! В одной из наших предыдущих статей мы рассказали о реализации функции асинхронного пинга в рамках задачи по созданию «пингера» для его дальнейшего использования при пентестах организаций с большим количеством рабочих станций. Сегодня мы поговорим о покрытии нашего пингера (логика и сетевая часть) модульными тестами.

Понятно, что необходимость написать код, который пройдет тестирование, — дисциплинирует и помогает грамотнее планировать архитектуру. Тем не менее, первая мысль о покрытии юнит-тестами асинхронного кода на 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *