Сферическое тестирование в вакууме: Как есть, как должно быть, как будет

Тестирование занимает особое место в работе каждого из нас. Это очень важная, сложная, не самая приятная, часто недоведённая до конца, недооценённая часть нашей работы. Поэтому я, как практикующий разработчик и технический руководитель небольшого стартапа, был рад возможности побеседовать с экспертом в этой области и задать ему свои наболевшие вопросы. Почему программисты не работают по TDD? Как правильно решать проблемы, связанные модульным тестированием системы, работающей с базой данных? Как избавиться от «человеческого фактора» и автоматизировать, в конце концов, тестирование пользовательского интерфейса?

2d8a56bb0fab8ebaed85a78590df32df.jpg

В рамках подготовки Joker 2016 вышел пост про легаси, который вызвал бурное обсуждение тестирования в Java, которое мы решили продолжить в интервью с Николаем Алименковым.

f7f4ca359574a0741464736ec15810b5.jpgНиколай — специалист в области разработки на Java уже с 12-летним стажем. Помимо основной рабочей деятельности, он — сооснователь и тренер тренингового центра XP Injection, активный участник и докладчик на международных конференциях. При его участии были организованы IT-конференции Selenium Camp, JEEConf, XP Days Ukraine и IT Brunch. Мы поговорили как о том, что можно улучшить в области тестирования в своей команде «здесь и сейчас», так и о том, к каким технологическим переменам нам следует готовиться в будущем.

 — Николай, мой первый вопрос — про самотестируемый код, использующий ассерты внутри самого себя. Твоё отношение к этой практике.

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

Ассертами заменяют больше не тесты, а рантаймовые проверки: например, если что-то равно null, то нужен Exception. Но ассерты же не являются обязательным к выполнению, можно их отключить, и тогда проверка не выполняется. Сейчас есть много других подходов, которые позволяют лучше сделать. Например, подход с аннотациями, где мы можем на входные параметры метода или на переменную поставить аннотацию NotNull. И эту аннотацию можно ставить в обработчики, которые будут проверять и бросать Exception. Сейчас имеются специальные validation-фреймворки, которые работают достаточно неплохо.

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

 — В теории все понимают, что test-driven development — это здорово и хорошо. Но на практике не весь код оказывается перекрыт модульными тестами. По-твоему, кроме лени разработчика, почему так бывает?

— А тут дело не в лени разработчика. Тут дело в двух причинах, на мой взгляд.

Первая — это то, что люди не умеют этого делать. Для того, чтобы разрабатывать по TDD, необходима подготовка. И мало этого, необходимо понимание инструментария, как им пользоваться и какое он дает преимущество. Человек, который проходит курсы, или сам изучает TDD, или садится работать с кем-то грамотным, кто уже работает по TDD, видит столько преимуществ в работе, что после этого ему становится понятно, что глупо так не делать.

А вторая причина — это то, что многие из разработчиков, особенно с завышенной самооценкой, «включают режим архитектора». Это когда человек посмотрел на задачку одним глазком и говорит: «Окей, все, я вижу. Вот здесь у меня будет фабрика, здесь будет такой-то паттерн, здесь — такой-то». И он сразу эти мысли выплескивает в код. Потом наступает момент, когда надо интегрировать всё то, что он «напроектировал», с остальным кодом. И становится ясно, что оно не интегрируется. Или же кто-то смотрит код на code review, и становится видно, что все методы гигантские, ничего не понятно, пятой вложенности if-ы. Наверняка все видели примеры, когда «Hello, world!» при помощи дизайн-паттернов можно изобразить так, что не разберешься, что перед тобой «Hello, world!».

Когда ты работаешь по TDD, то ты написал тест, и теперь твоя задача просто в том, чтобы он заработал. Твоя задача не сделать суперклассный дизайн. Задача сделать суперклассный дизайн возникает уже после того, как код заработал. Ты потом смотришь на него и говоришь: «Вот я простое решение написал. А можно его как-то сделать красивее? Можно сделать его как-то более элегантным? Или reusable?» И если нет — ну окей. Оно работает и работает, поехали дальше. То есть вот причины: «режим архитектора» и неумение писать тесты, и неумение работать с правильным инструментарием.

 — Ну, все-таки, мне кажется, тут дело не только в неумении писать тесты, но еще и в неумении писать тестируемый код. В сложностях, связанных с тем, чтобы написать тестируемый код.

— А его и не надо писать. Если ты работаешь по TDD, то перед тобой не стоит задача писать тестируемый код, потому что у тебя нет шансов написать его нетестируемым. Вот же в чем прикол!

 — Гм! Да уж! Простая идея, ничего не скажешь!

— Когда ты изначально в тесте нарисовал, как должен выглядеть код, чтобы тесту было удобно, то тогда у тебя заведомо получится тестируемый код, который красиво можно протестировать, который хорошо интегрируется, в котором классный API. А вот если ты постфактум решил уже реализовывать тест, то, конечно, тестируемость играет очень большую роль.

Если ты сначала написал какой-то код, а потом подходишь к нему и говоришь: «Ну, сейчас я свой первый юнит-тест на нем и напишу» — то часто возникает «затык». Ну вот классический пример — когда ты сделал три boolean-параметра в методе. И вот ты передаешь их: foo (true, false, true). А потом сам же видишь это и говоришь: «Ааа, что это такое вообще? Ничего не могу понять!» Или, например, для того чтобы вызвать один метод, тебе надо столько сетапа сделать, что ты уже забываешь, зачем ты вообще этот тест пишешь. Это ровно то, что происходит, когда ты пишешь постфактум тест.

А если ты работаешь по TDD, то происходит так: пишешь-пишешь ты тест, наконец смотришь: «О, вот красивый API получился! Именно так и должно выглядеть, так оно и надо!» И сгенерировал быстренько весь API. Потому что благо — если мы говорим про Java-разработку — очень много всего генерируется с помощью IDE из теста и не надо писать руками. В итоге тот человек, который работает по TDD, работает чуть быстрее за счет этого фактора. Он не пишет руками ни одной сигнатуры метода, ни одного конструктора, ни одного поля. Это все генерируется. Причем мегабыстро. Все, что пишет человек, который работает по TDD — это реализации методов. IDE берет на себя создание классов, конструкторов, геттеры, сеттеры, декларации методов, очень сильно в этом помогает и экономит огромное количество времени.

 — Но все-таки тесты иногда заставляют вмешаться в реализацию кода. Вот, например, хочется сделать private какой-нибудь, а приходится его открывать.

— Нет, не приходится открывать. Тут мы возвращаемся к тому, что не умеем правильно делать. Потому что когда ты хочешь протестировать что-то внутри, и оно закрыто у тебя в private — это обозначает всего лишь то, что твой класс, который ты тестируешь, стал обладать слишком многими responsibility. И это значит, что по-хорошему ты должен поменять несколько свой дизайн и вынести аспект кода, который ты хочешь протестировать, в отдельный класс, задача которого будет делать именно это. Опытному разработчику это подсказка, что его дизайн стал слишком комплексным. Можно, конечно, сделать один класс, в который напихать вообще все. И он будет уметь и печатать, и сохранять базу, и трансформировать в JSON, и высчитывать какие-то алгоритмы. И в итоге получится over-complicated solution. А когда работа идет в команде, то такой код очень тяжело будет модифицировать, потому что все по любому чиху будут лезть в этот код и менять его. И вот это подсказка. Если ты посмотрел и говоришь: «А как я это протестирую? Мне надо это открывать» — все, это сразу звоночек о том, что надо менять дизайн.

 — Как интересно. Окей. Ну тогда поговорим по поводу такой вещи, как необходимость в тесте использовать базу данных. Из-за необходимости постоянно иметь доступную базу данных, конфигурировать подключения, запуск модульных тестов, зависимых от базы данных, у нас превращается в ад. Мы их отключаем в сборочных скриптах, честно говоря. Что делать? Где-то мы пытались писать моки источников данных JDBC — очень сложно.

— Я, на самом деле, удивлен, что уже прошло столько времени, а до сих пор такой вопрос возникает. Это значит, что я не везде, где это мог донести, донес.

Я давно начал об этой проблеме говорить, и у меня есть доклад под названием «TDD for database related code, how is it possible?», который я рассказывал на некоторых больших конференциях. Вот здесь и здесь есть видео, а здесь — презентация, где я в режиме лайф-кодинга демонстрирую, каким образом это делать, каким образом подключается база данных, каким образом эта база данных потом используется в тестах.

Во-первых, не надо мокать API JDBC. Потому что если мы будем делать моки на JDBC API, что мы будем тестировать? Мы будем тестировать не то, что наша интеграция с данными правильно работает, а что мы послали вроде как более-менее верно сформулированный запрос SQL. Но в SQL можно легко перестроить запрос так, что он будет внешне другой, а по сути тот же самый. Например, переставить части AND-а между собой. Можно сказать: WHERE «user» = «Вася» AND «role» = «admin», а можно наоборот. Получается, что если мы будем делать моки на такие запросы, то при таком изменении, которое функционально ничего не поменяло, мы ничего не протестируем. Поэтому считается, что на базу данных надо писать именно интеграционные тесты, которые будут поднимать реальный контекст, поднимать реальную базу и на этом работать.

 — Но работать с настоящей базой — это очень медленно!

— Тут на помощь приходят in-memory базы данных. Есть старый добрый HSQLDB, то есть H2, который является, скажем так, его современной версией. Мало того, что H2 позволяет подниматься как in-memory-база, он ещё может работать в режимах синтаксиса поддержки разных баз данных. То есть можно сказать: «H2, поднимись и работай в синтаксисе MySQL. H2, поднимись и работай в синтаксисе Oracle». Они не на 100% совместимы но, тем не менее, большую часть проблем решают.

Плюс к этому написана уже масса статей, как быстро — именно быстро! — поднять базу с помощью RAM-диска. То есть мапить ее не на дисковую подсистему, а делать RAM-диск и мапить ее в оперативную память. Ведь для того, чтобы писать юнит-тесты, нам нет необходимости поднимать дамп на 10 гигабайт из продакшна, правильно? Мы же не собираемся гонять какие-то специфические перформанс-сценарии. Мы пишем обычные юнит-тесты, которые должны проверить нашу логику работы на небольшом количестве записей.

И, наконец, ещё один помощник в этом — это DbUnit. DbUnit, который позволяет очень легко манипулировать наборами тестовых данных, делать их очень удобно, делать их в XML, в JSON, в чем удобно. Но лучше всего, на мой взгляд, работать для этих задач в XML, потому что так мы получаем структурированные данные. И в этом случае мы просто легко можем иметь дата-сеты, сфокусированные на конкретные тесты. То есть, например, если нам необходимо проверить, что поиск работает, мы вставляем пять-десять записей, которые демонстрируют все разнообразие того, что мы ищем, и мы сфокусированы только на этих 10 записях. Причем мы вставляем только те колонки, которые нам необходимы.

Если мы говорим о ситуации, когда есть данные, которые связаны между собой, то здесь работают такие трюки как, например, отключение на лету констрейнтов. То есть мы, например, открываем Connection и говорим: «Отключите, пожалуйста, проверку всех констрейнтов». Тут зависит от базы данных: где-то это можно сделать глобально только, где-то это можно сделать в рамках коннекшена. Мы отключаем констрейнты и это позволяет нам, если мы ищем, например, юзеров, не отвлекаться на дополнительные данные, которые обязаны быть с юзерами, и не вставлять их.

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

То, что я перечислил — не единственное решение. Это — то, что есть, то, что известно всем, то, что уже известно давно. Делают все новые и новые решения, которые позволяют эту функциональность расширить, сделать более удобной и сделать тестирование баз данных комфортным.

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

— Ну это ты зря, потому что в последние годы тулов и подходов появилось огромное количество. Если говорить про web-интерфейс, то, действительно, когда-то давно мы ходили в обход браузера, выдумывали разные хаки, как можно через JavaScript что-то «дернуть» на самой странице. Но сейчас все стало гораздо проще, потому что появляются определенные стандарты. Если мы говорим про инструментарий для web-приложения, то это WebDriver, поддержка которого теперь уже расходится по самим браузерам, и уже сами браузеры внутрь вставляют реализацию WebDriver-а, которая позволяет контролировать браузер удаленно. И получается, мы из тестов полноценно управляем браузером, делаем все, что угодно с ним так же, как это делает обычный пользователь. Мы можем получать любую информацию из браузера, мы можем вытаскивать все логи, внутренние обработки, коммуникации с внешней средой.

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

По поводу реальных устройств — одно из направлений, которое сейчас активно развивается, это роботизированное тестирование. Когда печатаются на 3D-принтерах мини-роботы, в которых вставляют телефон, и они программируемы. То есть имеется микроконтроллер, этот микроконтроллер можно программировать, посылая ему команды. Соответственно, у робота есть перо с резиночкой на конце, вроде механического пальца. Устройство ставится в определенное положение, и дальше все это легко работает.

 — Как он считывает при этом то, что на экране?

— То, что на экране, считывается благодаря подключению телефона. Ты можешь считывать то, что на экране, и в зависимости от этого делать какие-то действия. Понятно, что оно не подойдет для всего, но работу с какими-то приложениями очень легко автоматизировать. Этот подход сейчас только активно развивается. Нет ещё production-решения, которое бы могло заменить тестировщиков в этой области. Но, тем не менее, это становится все более и более популярным.

 — Фантастика! Но нашей команде до этого, конечно, ещё далеко.

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

 — Эти люди получают тестовые сценарии?

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

Плюс к этому на сегодня еще одна очень активно развивающаяся область — это визуальное тестирование (например, при помощи Applitools). Если объяснить очень простыми словами, то выполняется некий сценарий под приложение с помощью WebDriver, если мы про web говорим, и снимаются скриншоты. И дальше реализованы алгоритмы сравнения скриншотов с целью определения изменений. Вот был, например, вчера сделан скриншот, и мы посмотрели на него и сказали: «С ним все круто. Это будет наш baseline». А теперь мы сегодня сняли скриншот. Есть алгоритмы анализа этих скриншотов, которые позволяют отследить, где именно какие изменения произошли, и сгруппировать их.

 — И, допустим, мы поменяли шрифт, картинка изменилась. И что? тест теперь не прошел?

— Нет! Они умеют отрабатывать такое. Они умеют группировать эти вещи. То есть они говорят: «Ага, здесь мы видим, что изменился шрифт». Просмотрели еще несколько скриншотов и говорят: «Это тип изменения №1 — поменялся шрифт». И ты должен его подтвердить. Ты должен сказать: «Это правильно, это мы действительно поменяли в приложении шрифт». И, кроме того, ты можешь в настройках сказать, что если еще раз произойдет изменение шрифта, то просто не уведомлять об этом.

Это очень интересно, потому что это дает нам возможности тестировать еще более широко. Вот смотри, например, у нас есть какой-то тестовый сценарий, и мы хотим его выполнить, чтобы проверить, что все теперь хорошо. А что такое «все теперь хорошо»? Раньше мы должны были бы явным образом указать: «В этом поле — такие-то данные, тут в подписи показалось вот это, тут выскочило нотификационное окошко», и так далее. А визуальное тестирование вместо этого позволяет просто показать, как должен выглядеть скрин в случае, если все хорошо. И тогда если ты случайно забыл про какую-то проверку, ну, например, про одно из полей, которое тоже надо было проверять, то визуальное тестирование позволит это сделать автоматически.

Если у тебя изменилась форма, оно тебе скажет: «Ого, а вот тут новое поле появилось». Ты говоришь: «Confirmed, появилось, все правильно». И у тебя новый baseline создается. И это большое направление сейчас, очень популярное.

И вот еще один из примеров, как может быть использовано визуальное тестирование: когда необходимо протестировать визуально сайт на разных разрешениях экрана. Представь, какое это количество ручной работы. Есть четыре основных браузера: Firefox, Chrome, IE, Safari, помножь это на количество разрешений. Это практически нереально делать руками. А инструменты визуального тестирования позволяют тебе указать разрешение экрана, затем получить baseline-скриншот для заданного разрешения и так далее.

В общем, в тестировании пользовательских интерфейсов сейчас все хорошо.

 — Звучит потрясающе.

— И это реально работает, что интересно. Одна из наших конференций, Selenium Camp, целиком посвящена именно современным технологиям в автоматизации тестирования. Сейчас продукт Selenium больше известен как WebDriver. Но когда мы начинали, то еще WebDriver-ом это не называлось, это называлось «Селениумом». И мы тогда стали первой конференцией в мире, которая была посвящена целиком и полностью этому продукту.

Если кто хочет по этой теме глубже посмотреть, то у нас есть огромное количество видео. В открытом доступе абсолютно за все годы. Можно пойти и посмотреть, как это делают в известных компаниях наподобие Google, Facebook и прочих.

 — Какие сейчас, на твой взгляд, наиболее активные точки роста у методов тестирования? В какую сторону будет в ближайшее время развиваться вообще эта дисциплина — тестирование?

— Ну, понятное дело, что глобально развитие направлено на автоматизацию.

 — Автоматизацию тестирования UI?

— Не обязательно! Мы ведем речь еще и об автоматизации, например, тестирования API.

Одна из проблем, которая стоит перед разработчиками, это если есть большое количество бизнес-сценариев, то должен кто-то быть, кто руками должен будет создавать эти сценарии и «учить» автоматизированные тесты выполнять эти сценарии. То есть все равно должен присутствовать человек, на котором висит ответственность, чтобы он не пропустил каких-то сценариев, чтобы он перебрал все комбинации, чтобы ничего не было недопокрыто. И это плохо. Потому что мы опять остаемся с человеческой составляющей.

А сейчас ведется работа по достаточно интересным направлениям — автоматическим генерациям тестовых сценариев на основании бизнес-моделей и workflow самого приложения. Идея очень проста. Если мы представим себе работу приложения неким workflow-графом с переходом состояний, и на каждом из переходов состояний мы будем указывать, какого типа данные необходимо предоставить в приложение, чтобы этот переход осуществился, то дальше, если мы все такие переходы опишем, то мы получим граф нашего приложения. То есть мы поймем, как можно по нему ходить и в какие точки мы можем доходить, какие точки являются финальными, откуда мы уже никуда не можем выбраться, и прочее.

 — Это лишь до некоторой степени точности может описать приложение? Естественно, не все его аспекты работы?

— Ну почему? Как раз все аспекты можно описать. Потому что любой бизнес-сценарий должен быть выражен в этом flow. Если он не выражен в этом flow, то это значит, что он не покрыт. Это очень натурально, потому что эти flow являются представлением клиента, представлением заказчика, представлением конечного пользователя о вашем продукте. Я о любом продукте думаю в разрезе flow, что я могу сделать, какие шаги я могу пройти, каких результатов я могу добиться. И это красиво визуализируется в виде графа. И после этого на этот граф натравливается своеобразный робот, который генерирует все возможные сценарии, потому что у него есть направление, как можно по этому графу ходить. Где-то он заходит в повторные веточки, там можно указывать глубину и так далее. И он автоматически генерирует все возможные сценарии. И дальше все, что нужно сделать, это просто автоматизировать каким-нибудь своим инструментом, который вы используете для тестирования, сами переходы. Ну, то есть нужно сказать, например, что под логином подразумевается либо вызов API логина, либо что я кликну на клавишу «Логин». И тогда получается, что работа автоматизаторов будет заключаться в автоматизации конкретных шагов, а уже сами сценарии будут появляться на базе прохода вот по такому графу. Это очень интересное направление, потому что оно позволит избежать многих проблем с поддержкой тестовых сценариев. Когда, например, у вас написана тысяча сценариев, и меняется какой-нибудь промежуточный шаг, то надо пойти во все тысячу сценариев и учесть его.

Плюс будут развиваться инструменты автоматизации, причем всей. Сейчас на уровне IDE очень хорошо делается интеграция со всеми инструментами для тестирования. Есть плагины, которые позволяют запускать юнит-тесты на тот код, который только что был изменен. И запускать их в background-е. Получается, разработчик работает, и параллельно, как только IDE увидит, что происходит изменение в коде, она в фоне запускает юнит-тесты и дает быстрое уведомление о том, что юнит-тесты прошли или провалились.

Ещё элементарный пример, как современные средства разработки помогают сэкономить время и помогают лучше интегрироваться с тестированием, это плагин для того, чтобы делать удаленные запуски тестов, чтобы не загружать свою локальную машину и продолжать работать. Существует, к примеру, плагин для CI-системы TeamCity. Сценарий работы разработчика выглядит так: он работает, и в момент, когда он подумал: «Интересно, мои тесты проходят или нет?» — вместо того, чтобы отвлекаться, запускать все тесты у себя, ждать — он запускает их на CI-сервере.

 — А для Jenkins есть такой плагин?

— Эх, к сожалению, нет.

 — Жаль!

— К сожалению, для Jenkins такого плагина нет, но есть выход! В Дженкинсе просто рекомендуют использовать подход с ветками, и можно свою ветку отправить на CI. Но для этого тогда надо сначала закоммититься, отправить свою веточку и сказать: «По моей веточке прогони определенное quality gate». И вот уже эти quality gate будут являться презентацией того, что все с кодом нормально и что все прошло.

Но мир все равно движется к тому, чтобы инструменты IDE давали возможность максимально быстро получать обратную связь. Если мы сравним с тем, как было раньше, когда люди запускали только nightly builds, то тут уже возникла целая пропасть. Все будет развиваться, на мой взгляд, именно в этом направлении.

И еще одно направление, которое уже практически готово, это то, что такие инструменты, как WebDriver, будут становиться стандартными. И этот API будет становится стандартным. На текущий момент он почти уже стандарт W3C, а это обозначает, что сами производители инструментов, производители браузеров — и я надеюсь, что в будущем, если мы говорим про десктопные системы, и производители операционных систем — будут поддерживать тестируемость приложений, разработанных для этой операционной системы или для этого браузера. Потому что точно так же, как мы вкладываем тестируемость в наш код, когда пишем тест перед кодом, точно так же если иметь стандартный API для тестирования и поддерживать его, то приложения будут заведомо хорошо тестируемы.

 — Надеюсь, что вскоре так и будет. Большое спасибо за интервью!

— Счастливо! Хорошего вечера!


Если хотите узнать больше о тестировании, бенчмаркинге и QA, вам скорее всего окажутся интересны следующие доклады Joker 2016 (СПб, 14–15 октября).
  • Причуды Stream API
  • Tracing distributed services: experiences with implementing APM for the JVM
  • Мифы и факты о медленной Java
  • Перформанс: Что В Имени Тебе Моём?

А если вы хотите полностью погрузиться в тему тестирования во всех его проявлениях, рекомендуем вам обратить внимание на конференцию Гейзенбаг (Москва, 10 декабря).

Пользуясь случаем, поздравляем всех тестировщиков с профессиональным праздником 09.09!

Комментарии (0)

© Habrahabr.ru