Всем привет! В одной из наших предыдущих статей мы рассказали о реализации функции асинхронного пинга в рамках задачи по созданию «пингера» для его дальнейшего использования при пентестах организаций с большим количеством рабочих станций. Сегодня мы поговорим о покрытии нашего пингера (логика и сетевая часть) модульными тестами.
Понятно, что необходимость написать код, который пройдет тестирование, — дисциплинирует и помогает грамотнее планировать архитектуру. Тем не менее, первая мысль о покрытии юнит-тестами асинхронного кода на 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 — отличный фреймворк, прекрасно подходящий для подобных целей. Кроме того, как водится, в процессе покрытия юнит-тестами было выявлено несколько серьезных багов :) Само собой, нам удалось и сэкономить много часов ручного тестирования и отладки кода. С момента внедрения кода пингера в продукт, в нем был выявлен всего один мелкий баг, связанный с невнимательностью при написании кода, а значит, время на разработку и написание юнит-тестов потрачено не зря!
Отсюда можно сделать выводы:
- Модульное тестирование очень полезная вещь, юнит-тестами в идеале должен быть покрыт весь код.
- Практически любая задача покрытия кода тестами решаема! Нужно лишь разбить тестируемый код на достаточное количество абстракций.
Всем спасибо за внимание!
Автор: ptsecurity