Начинаем писать тесты (правильно)
Как начать писать тесты? Сколько нужно писать? На что их нужно писать, а на что — не нужно? Стоит ли всегда применять TDD? Если вас интересуют ответы на эти вопросы, то вы читаете правильную статью. В своей жизни я написал не одну тысячу тестов всех мастей для разных платформ, использовал во все поля TDD и ставил процесс тестирования в командах, проектах и даже целых компаниях. И теперь я попробую обобщить этот опыт и поделиться им.
Тестирование, как и многое в программировании, стало культом карго. Вместо осознанного движения, разработчики пытаются следовать популярным методологиям, слепо верить тому, что пишут в документации, покрывать код на 100% тестами. Я был свидетелем удаления папки с тестами (в 40 тысяч строк кода) по причине того, что их стало невозможно поддерживать. Такое тестирование чаще приводит к обратному эффекту — разработка становится дороже, а процесс медленнее, и даже если наблюдается позитивный эффект, то он дается слишком дорого.
Основная цель этой статьи — дать вам целостное понимание смысла тестирования. Понимая суть, вы сможете лучше мыслить критически и понимать, к чему нужно идти. Ну и, конечно, будет немного практических советов.
Подписывайтесь на канал Кирилла Мокевнина в Telegram — чтобы узнать больше о программировании и профессиональном пути разработчика
Начнем, пожалуй, с самого главного вопроса: зачем нам вообще нужно тестировать?
Чтобы быть уверенными в работоспособности нашего продукта. Заметьте, что я не написал «функций», «модуля», «кода» или «проекта». В конечном итоге имеет значение только то, что конечный продукт, которым пользуются (не всегда пользователи), работает, и делает он это хорошо. Хотя прямо сейчас это может показаться капитанством, но, как вы увидите позже, ориентация на цель позволит нам принимать правильные решения.
Следующий ключевой тезис не является особенностью процесса тестирования. Задачи можно условно поделить на два типа: они либо завершены, либо нет, а завершенность задач второго типа — это шкала, где 0 — это «ничего не сделано», а 1 — это сделано на 100%. При решении таких задач 100%-решение часто оказывается недостижимым из-за сверхвысоких накладных расходов.
Приведу прекрасный пример. Для многих сервисов критично такое понятие как SLA или, проще говоря, доступность сервиса. Например, на хостинговых площадках пишут что-то в духе «мы обеспечиваем доступность 99.9% наших серверов». Давайте прикинем, сколько часов за год хостинг может оказаться недоступен в рамках его SLA: 0.001 * 365 * 24 = 8.7 . В принципе, неплохо.
Предположим, что обеспечение такого уровня доступности обходится компании в 1000$ . А во сколько обойдется компании добавление каждой новой девятки в конце? То есть обеспечение 99.99 , 99.999 и так далее. Насколько мне известно, на таком уровне обеспечения происходит экспоненциальный (взрывной) рост стоимости. Я уже не говорю про то, что 100%-доступность является фантастикой.
Этот пример ярко демонстрирует то, что в задачах с плавающим результатом главным принципом является «максимальный результат за минимальные ресурсы». Другими словами, ищется баланс, при котором мы получаем результат, удовлетворяющий стейкхолдеров (заинтересованные лица), за приемлемый бюджет/сроки.
Теперь возвращаемся к нашим тестам и обнаруживаем, что тесты относятся именно к этому типу задач. Добавление первых тестов в проект дает невероятный эффект. Покрытие в 50% (половина кода вызывается в тестах) получается почти сразу, и по сравнению с отсутствием тестов — мы на два корпуса впереди. Дальше ситуация начинает меняться, и где-то на уровне 70-90% начинается резкое замедление роста покрытия, тесты становятся все более точечными, дорогими. Возрастает сложность их поддержки, рефакторинга.
Этот процесс бесконечен. Добиться 100% покрытия очень дорого и, скорее всего, неоправданно (см. пример выше). Кроме того, никакие тесты не дают вам полную гарантию работоспособности.
Кроме количества тестов и их качества, на стоимость также влияет то, какой тип тестов мы используем. Существует множество классификаций видов тестов, таких как: «по знанию системы», «по степени автоматизации», «по времени проведения тестирования». На этом этапе нас интересует только одна классификация: «по степени изолированности компонентов»:
- Модульное тестирование
- Интеграционное тестирование
- Системное тестирование (приемочное)
Именно здесь начинаются проблемы. Во-первых, идут настоящие религиозные войны на тему того, что называть модульным тестированием, а что не называть. Во-вторых, эти типы тестов подаются как нечто конкретное с большим количеством требований для того, чтобы соответствовать одной из этих категорий. Такое положение вещей приводит к тому, что программисты думают, в первую очередь, не о результате, а о том, пишут ли они юнит-тесты в соответствии с канонами, или нет.
В действительности же нет никаких четких разделений на три уровня. Даже если вы тестируете чистую функцию (модульное тестирование), она выполняется на конкретном железе, и потенциально, на другом может перестать работать как ожидалось (это тоже в каком-то смысле интеграционное тестирование).
Так вот, есть только шкала. Чем более простую и мелкую часть системы мы тестируем — тем дешевле тесты, чем более сложную (составную) — тем дороже. И ваша задача как специалиста — исходить не из того, чтобы соответствовать своим представлениям о видах тестирования, а писать тесты так, чтобы они в идеале покрывали большее число кейсов при небольших затратах. Я уверен, что на этой фразе некоторые разработчики напряглись, потому что в их картине мира нужно обязательно писать изолированные юнит-тесты, а приемочные должны писать только тестировщики. Не буду разводить полемику, просто скажу, что бывает по-разному. Есть проекты, в которых процент юнит-тестов (в самом жестком понимании) составляет доли процента от всех остальных тестов (как в Хекслете, хе-хе), а есть те, где пишут только приемочные тесты (отдельные тестировщики).
Теперь вы готовы, и я попробую ответить на вопросы, поставленные в начале статьи. Предположим, что вы пишете программу (утилиту командной строки), которая принимает на вход файл и слово, которое нужно найти в этом файле. В результате своей работы программа печатает на экран все строчки из файла, в которых встречается это слово. Такая утилита действительно существует и называется grep . С ней знакомо большинство разработчиков.
Обычно в таких программах не всегда сразу понятно, какой будет архитектура. Многое зависит от того, что будет добавлено в процессе, например, форматы вывода, поддерживаемые форматы входа, обход директорий (рекурсивный), нечеткий поиск и многое другое.
Основной наблюдаемый мной анти-паттерн в разработке подобных библиотек — это тесты на внутренние мелкие компоненты. Те самые юнит-тесты. Почему такой подход непродуктивен? Возможно, это и не очевидно, но такое тестирование, хоть и является модульным, но не является дешевым и качественным. Но, как…?
Мы уже говорили о том, что архитектура проекта еще неизвестна, и, как правило, внутреннее разделение на файлы/модули/классы/функции, меняется с космической скоростью. В течение часа все может быть переписано несколько раз. Но теперь вместе с кодом нужно постоянно править тесты, что начинает раздражать. Программист начинает сомневаться в том, что они вообще ему нужны, и нередко просто перестает их писать. Другие продолжают мучаться и переписывать их, хотя чаще происходит другое. Написанные тесты начинают вас сковывать и мозг шлет команды «ты потратил время, оставь все как есть». Постепенно рефакторить становится все сложнее и ленивее. Это очень похоже на ситуацию, когда предприниматель инвестировал деньги в новое направление и, даже если бизнес уже тонет, ему тяжело отказаться, ведь было потрачено столько сил и средств (в экономике это называют sunk cost fallacy , — прим. ред.).
Так как же лучше написать тест? Надеюсь, вам уже стало очевидно, что нужно найти достаточно высокую точку входа в нашу программу, которая не зависит от внутренней реализации, и при этом выполняет поставленную задачу.
Если мы попробуем взять самый высокий уровень — прямой запуск программы в консоли, то, скорее всего, мы столкнемся с рядом проблем, такими как запуск отдельного процесса, чтение стандартных потоков и других. В случае нашей программы такое тестирование уже можно называть системным, ведь мы проверяем работу на максимально высоком уровне, вообще не касаясь внутренней реализации. Хотя такой тест и не является проблемой для опытного разработчика, в целом, стоимость подобного теста и для данной библиотеки можно назвать максимальной.
Более низкий уровень — это функция, которая принимает на вход путь до файла и подстроку для поиска, а на выходе (не печатает на экран!) отдает готовый результат, так, чтобы осталось только напечатать его. Такой вид тестов обладает самым лучшим балансом «убедиться в том, что все работает/стоимость». Они косвенно затрагивают все используемые внутренности, не зависят от реализации, очень просты в написании и крайне дешевы в поддержке. По мере стабилизации архитектуры можно добавлять тесты более низкого уровня (если становится понятно, что сложность системы слишком высока).
TDD
Описанная методика особенно хорошо работает в связке с подходом, когда тесты пишутся до кода (вместе с кодом).
Существует миф о том, что тесты нужны только для регресса, то есть для уверенности, что новый код не сломал старый. Это далеко не так. Более того, это следствие написания тестов как таковых. В некоторых ситуациях первостепенная цель написания тестов — это ускорение разработки. Да-да, вы не ослышались, написание тестов до кода/одновременно с кодом, приводит к серьезному ускорению разработки. Чаще всего такие ситуации связаны с тем, что на вход подаются сложные данные, которые как-то трансформируются и прокидываются дальше. Тестировать руками (во время разработки) такой код очень сложно, нужно подготавливать данные, нужно проверять, что результат соответствует ожидаемому.
Важно понимать, что ускорение возможно только после того, как вы наберетесь опыта и начнете чувствовать себя уверенно в мире автоматизированного тестирования. К тому же, есть виды тестирования, где писать тесты до кода сложно либо практически невозможно. К таким тестам, например, относится приемное тестирование через браузер.
Второе серьезное преимущество TDD заключается в том, что при проектировании кода мы начинаем думать не о том, как сейчас клево насоздаем файлов и разнесем по ним функции, создав десятки абстракций, и начнем думать о важных вещах. О том, как будет использоваться моя библиотека. Удивительно, но начать смотреть с такого угла (а этому учат всех стартаперов, customer development во все поля) непросто, все время хочется окунуться в прекрасный мир архитектуры.
Проектирование внешнего api — действительно важная задача, которой стоит уделить хотя бы немного времени. Именно она больше всего влияет на ту свободу действий при переработке, которую вы получите в будущем. И именно этот уровень чаще всего является той точкой, от которой стоит отталкиваться при формировании планов тестирования.
Дополнительные ссылки
- Видео версия статьи
- Бережливое тестирование
Написание первых тестов
Я в целом противник методики TDD. На мой взгляд, при решении любой задачи нужно в первую очередь решить её, а уже потом покрывать тестами. Писать тесты на несуществующий код не имеет особого смысла, особенно если вы начинаете проект с нуля и ещё не до конца представляете как он будет выглядеть и работать (но уже знаете чего вы от него хотите). В случае разработки первой версии mkdev перед нами стояли такие важные задачи как автоматизация рутинных процессов обучения и предоставления ученикам и менторам удобного интерфейса для отслеживания выполнения заданий. После релиза первой версии (который произошёл на 23% быстрее, так как не пришлось сразу же тратить время на тесты) появились ещё более важные задачи: код для платежей, подписок, оповещений, фоновых задач и т.п.
Теперь, когда мы уже точно знаем в какую сторону движется разработка mkdev и какие его фичи прижились и нужны пользователям, нам необходимо покрыть их тестами. Этим я сегодня и займусь: в этом материале мы пройдём от состояния, в котором в приложении для тестов не настроено вообще ничего, до покрытия тестами основной функциональности проекта.
Внимание! Такое «позднее» покрытие тестами оправдано если у вас уже есть опыт написания надёжного работающего кода без значительных багов и при этом проект принадлежит вам лично, а не компании, на которую вы работаете. В случае наёмной работы обязательно покрывайте свой код тестами. Я ни в коем случае не агитирую против тестов. Я агитирую против траты времени на тесты до того, как вы решили поставленную перед вами задачу.
Приступим! В mkdev мы будем использовать rspec-rails, capybara и factory_girl. Нашей задачей на сегодня будет написать feature спеки, то есть тесты, которые проверяют правильность работы приложения со стороны пользователя. Возможно, вы уже столкнулись с написанием подобных тестов в одном из заданий курса по Rails ;)]
Добавляем необходимые гемы в Gemfile :
group :test do gem 'rspec-rails', '~> 3.0.0' gem 'factory_girl_rails' gem 'capybara' end
Следуя инструкции гема rspec-rails генерируем необходимые для тестов файлы:
rails generate rspec:install
Теперь необходимо подключить capybara и factory_girl в spec/rails_helper.rb :
# spec/rails_helper.rb # . require 'rspec/rails' # эта строчка там уже была require 'capybara/rails' # вот эту строчку добавили # . RSpec.configure do |config| # . config.include FactoryGirl::Syntax::Methods # . end
Можно переходить к написанию первого теста! Создаём папку spec/features/ , в ней файл course_spec.rb . Как видно из названия, мы будем тестировать что-то связанное с курсами. Для каждого теста в этом файле нам понадобится залогиненный пользователь:
describe 'Taking a course' do let!(:user) create(:user, email: "bob@mail.ru", password: "qweqweqwe") > before(:each) do login("bob@mail.ru", "qweqweqwe") end end
Естественно, этот код ещё не работает, так как мы не создали factory для модели User и не создали helper метод login , внутри которого мы будем проходить через процедуру логина пользователя (подозреваю, мы будем часто логиниться в feature спеках, поэтому лучше сразу же заDRYим этот код).
# spec/factories/subscription.rb FactoryGirl.define do factory :subscription do active true expire_date Date.today + 4.weeks end end # spec/factories/user.rb FactoryGirl.define do factory :user do first_name "Boris" last_name "Spider" password "secret123" password_confirmation password > customer_id "superbad" after(:create) do |user| user.subscription.update_attributes(active: true, expire_date: Date.today + 4.weeks) end end end
# spec/support/login_helper.rb def login(email, password) visit root_path click_link "Войти" fill_in :user_email, with: email fill_in :user_password, with: password click_button "Войти" end
Теперь гораздо лучше! Попробуем написать первый тест:
# spec/features/course_spec.rb # . it "opens courses page after login" do expect(page).to have_content "Текущие курсы" end # .
Тест, запущенный командой bundle exec rspec , успешно проходит. Теперь я хочу протестировать что если в системе существует курс с заданиями, то пользователь видит ссылку Начать курс, кликает по ней и видит после этого название первого задания. Нам понадобятся factory для Course и Task :
# spec/factories/course.rb FactoryGirl.define do factory :course do title "Ruby" end end # spec/factories/task.rb FactoryGirl.define do factory :task do position 1 title "Task 1" text "Do some crazy shit!" end end
Сам тест выглядит так:
# spec/features/course_spec.rb # . context "can start and go through course" do before(:each) do course = create(:course, title: "Ruby on Rails") task = create(:task, course: course, title: "Простой контроллер") end it "can start course" do visit dashboard_root_path click_link "Начать курс" expect(page).to have_content "Простой контроллер" end end # .
Судя по всему новый пользователь может стартовать курс без каких-либо проблем. Протестируем теперь что он не увидит кнопку «Принять» после отправки задания на проверку, а так же то что после отправки на проверку в журнал задания добавляется новая запись.
# spec/features/course_spec.rb # . context "sends a task for review" do before(:each) do visit dashboard_root_path click_link "Начать курс" click_link "Простой контроллер" fill_in "user_task_pull_request_url", with: "http://github.com/pulls/1" click_button "Добавить PR" click_link "На проверку" end it "updates task journal" do expect(page).to have_content "Отправлено на проверку" end it "doesn't show mentors buttons" do expect(page).not_to have_content "Принять" end end # .
Обратите внимание: я не использую больше одного expect’а в каждом тесте. Это считается хорошим стилем, потому что в таком случае каждый тест проверяет одну конкретную вещь, а не сразу же сотню различных аспектов. Таким образом если упадёт один тест, то мы знаем что проблема только в одной части кода. Если бы у нас было несколько проверок в одном тесте, то мы бы не смогли узнать результат тех, что идут ниже проваленной проверки. Например:
it "updates task journal" do expect(page).to have_content "Отправлено на проверку" expect(page).not_to have_content "Принять" end
Если первый expect вывалится с ошибкой, то мы узнаем что не работает запись в журнал. Но так как ошибка уже вывалилась, то мы не узнаем выводится ли пользователю кнопка «Принять» до тех пор, пока не починим предыдущую проверку.
Ещё один важный момент: обратите внимание насколько точно эти тесты воспроизводят действия пользователя. Я не проверяю сам код, которые отвечает за то, с чем столкнётся пользователь, я тестирую только отклик приложения на его действия. Внутренняя реализация моделей и контроллеров может полностью поменяться, но процедура работы с курсами и заданиями не должна сломаться после этих изменений. Именно это и гарантируют нам feature тесты: что приложение работает правильно со стороны пользователя. Согласитесь, что это, пожалуй, самое важное в любом приложении.
Пожалуй, стоит добавить ещё парочку небольших тестов, проверящих следующее:
- у простых пользователей нет доступа к админке
- у админов есть доступ к чему угодно
- у редакторов нет доступ к редактированию пользователей
Приведу целиком финальный тест, который у меня получился:
# spec/features/admin_spec.rb require 'rails_helper' describe 'Accessing admin' do let!(:user) create(:user, email: "bob@mail.ru", password: "qweqweqwe") > before(:each) do login("bob@mail.ru", "qweqweqwe") end it "doesn't show admin for regular user" do expect(page).not_to have_content "Админка" end it "redirects user to account when he tries to access admin" do visit admin_root_path expect(page).to have_content "Текущие курсы" end context "admin user" do before(:each) do user.add_role :admin visit admin_root_path end it "shows link to admin" do expect(page).to have_content "Админка" end it "shows link to users managment" do expect(page).to have_content "Ученики" end end context "editor user" do before(:each) do user.add_role :editor visit admin_root_path end it "shows link to admin" do expect(page).to have_content "Админка" end it "shows link to users managment" do expect(page).not_to have_content "Ученики" end end end
Это были два первых написанных теста для mkdev.me. В другой статье я расскажу про то, в каких случаях и как писать тесты на модели, опять же, используя тесты mkdev.me в качестве примеров.
© Copyright 2014 — 2023 mkdev | Privacy Policy
Зачем и как писать тесты? — PHP: Автоматическое тестирование
Какую главную задачу должны решать тесты? Этот вопрос невероятно важен. Ответ на него дает понимание того, как правильно писать тесты и как писать их не нужно.
Представьте, что вы написали функцию capitalize($text) , которая делает заглавной первую букву переданной строки:
capitalize('hello'); // 'Hello'
Вот один из вариантов ее реализации:
namespace StringUtils; function capitalize($text) $firstSymbol = strtoupper($text[0]); $restSubstring = substr($text, 1); return "$firstSymbol>$restSubstring>"; >
Что мы делаем после создания функции? Проверяем, как она работает. Например, открываем REPL и вызываем функцию с разными аргументами:
-a Interactive shell php > echo capitalize('hello'); 'Hello' > echo capitalize('how are you?'); > 'How are you?'
Таким нехитрым способом убеждаемся, что функция работает. По крайней мере для тех аргументов, которые мы передали в нее. Если во время проверки заметили ошибки, то исправляем функцию и повторяем все заново.
Фактически, весь этот процесс и есть тестирование. Но не автоматическое, а ручное. Задача такого тестирования — убедиться, что код работает, как надо. И нам совершенно без разницы, как конкретно реализована эта функция. Это и есть главный ответ на вопрос, заданный в начале урока.
Тесты проверяют, что код (или приложение) работает корректно. И не заботятся о том, как конкретно написан код, который они проверяют.
Автоматические тесты
Все, что требуется от автоматических тестов — повторить проверки, которые мы выполняли, делая ручное тестирование. Для этого достаточно старого доброго if и исключений.
Даже если вы не знакомы с исключениями, ничего страшного. В этом курсе достаточно знать две вещи: для чего они нам нужны и какой у них синтаксис. До сих пор в курсах Хекслета вы встречались с ошибками, которые возникают непроизвольно: вызов несуществующей функции, обращение к несуществующей переменной и так далее. Но ошибки можно порождать самостоятельно с помощью исключений, что необходимо для нашей ситуации. Исключения создаются такой конструкцией:
// Дословно: выбросить новую ошибку // Исключения бросают throw new Exception('Описание исключения'); // Код, следующий за этим выражением, не выполнится, а сам скрипт завершится с ошибкой print_r('nothing');
if (capitalize('hello') !== 'Hello') // Если результат функции не равен ожидаемому значению, // выбрасываем исключение и завершаем выполнение теста throw new Exception('Функция работает неверно!'); >
Из примера выше видно, что тесты — это точно такой же код, как и любой другой. Он работает в том же окружении и подчиняется тем же правилам, например, стандартам кодирования. А еще он может содержать ошибки. Но это не значит, что надо писать тесты на тесты. Избежать всех ошибок невозможно, да и не нужно, иначе стоимость разработки стала бы неоправданно высокой. Обнаруженные ошибки в тестах исправляются, и жизнь продолжается дальше 😉
В коде тесты, как правило, складывают в специальную директорию в корне проекта. Обычно она называется tests, хотя встречаются и другие варианты:
Структура этой директории зависит от того, на базе чего пишутся тесты, например, на базе какого фреймворка. В простых случаях она отражает структуру исходного кода. Если предположить, что наша функция capitalize($text) определена в файле src/StringUtils.php, то ее тест лучше поместить в файл tests/StringUtilsTest.php. Слово Test в имени модуля с тестами используется только для более явного обозначения цели файла.
Теперь при любых изменениях, затрагивающих эту функцию, важно не забывать запускать тесты:
# Если все хорошо, код молча выполнится # Если есть ошибка, то будет выведено сообщение об ошибке
Как пишутся тесты
Тесты — это не магия. Нам, как разработчикам, нужно самостоятельно импортировать тестируемые функции, вызывать их с необходимыми аргументами и проверять, что функции возвращают ожидаемые значения.
Если поменялась сигнатура функции (входные или выходные параметры, ее имя), то придется переписывать тесты. Если сигнатура осталась той же, но поменялись внутренности функции:
function capitalize($text) $firstSymbol = mb_strtoupper($text[0]); $restSubstring = mb_substr($text, 1); return "$firstSymbol>$restSubstring>"; >
Тогда тесты должны продолжать работать без изменений. Хорошие тесты ничего не знают про внутреннее устройство проверяемого кода. Это делает их более универсальными и надежными.
Сколько и какие нужно писать проверки?
Невозможно написать тесты, которые гарантируют 100% работоспособность кода. Для этого потребовалось бы реализовать проверки всех возможных аргументов, что физически неосуществимо. С другой стороны без тестов вообще нет никаких гарантий, только честное слово разработчиков.
При написании тестов нужно ориентироваться на разнообразие входных данных. У любой функции есть один или несколько основных сценариев использования. Например, в случае capitalize() — это любое слово. Достаточно написать ровно одну проверку, которая покрывает этот сценарий. Дальше нужно смотреть на «пограничные случаи». Это ситуации, в которых код может повести себя по-особенному:
- Работа с пустой строкой
- Обработка null
- Деление на ноль (в большинстве языков вызывает ошибку)
- Специфические ситуации для конкретных алгоритмов
Для capitalize() пограничным случаем будет пустая строка:
if (capitalize('') !== '') throw new Exception('Функция работает неверно!'); >
Добавив тест на пустую строку, мы увидим, что вызов показанной в начале урока функции capitalize() завершается с ошибкой. Внутри нее идет обращение к первому индексу строки без проверки его существования. Исправленная версия кода:
function capitalize($text) if ($text === '') return ''; > $firstSymbol = mb_strtoupper($text[0]); $restSubstring = mb_substr($text, 1); return "$firstSymbol>$restSubstring>"; >
В большом числе ситуаций пограничные случаи требуют отдельной обработки, наличия условных конструкций. Тесты должны быть построены таким образом, чтобы они затрагивали каждую такую конструкцию. Но не забывайте, что условные конструкции могут порождать хитрые связи. Например, два независимых условных блока порождают 4 возможных сценария:
- Функция выполнилась так, что не был выполнен ни один условный блок
- Функция выполнилась так, что был выполнен только первый условный блок
- Функция выполнилась так, что был выполнен только второй условный блок
- Функция выполнилась так, что были выполнены оба условных блока
Комбинация всех возможных вариантов поведения функции называется цикломатической сложностью. Это число показывает все возможные пути выполнения программы внутри функции. Цикломатическая сложность — хороший ориентир для понимания того, сколько и какие тесты нужно написать.
Иногда пограничные случаи не связаны с условными конструкциями. Особенно часто такие ситуации встречаются там, где есть вычисления границ слов или массивов. Такой код может работать в большинстве ситуаций, но только в некоторых может давать сбой:
// В этой функции забыли отнять единицу от длины // Этот код сработает в некоторых ситуациях, когда последний элемент null или в массиве нет элементов // Но в остальных случаях вернет неверное значение function last($elements) return $elements[count($elements)]; >
Проверка входных данных
Особняком стоят ошибки типов входных данных. Например, в функцию capitalize() можно передать число вместо строки. Как она должна себя вести в таком случае? Нужно ли писать такой тест?
Еще один интересный вопрос. Нужно ли внутри capitalize обрабатывать такие ситуации? Ответ — не нужно. Иначе код превратится в мусорку, а пользы от этого мало. Все равно должны быть тесты, которые проверяют, что система работает в целом, а они обычно выявляют проблемы кода на более нижних уровнях.
Ответственность за передачу правильных данных в функцию capitalize() лежит не на ней, а на коде, который вызывает эту функцию. И если он хорошо протестирован, то подобная ошибка либо обнаружится, либо вообще не возникнет.
Но даже если ошибка обрабатывается внутри функции, не надо пытаться написать тесты, покрывающие каждую ошибку. Это выливается в огромное число тестов, которые требуют поддержки и времени на написание. Нужно уметь вовремя остановиться и двигаться дальше, к покрытию другого кода.
Собирая все вместе
В конечном итоге мы получили такую структуру директорий:
if (StringUtils\capitalize('hello') !== 'Hello') throw new \Exception('Функция работает неверно!'); > if (StringUtils\capitalize('') !== '') throw new \Exception('Функция работает неверно!'); > echo 'Все тесты пройдены!';
Открыть доступ
Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно
- 130 курсов, 2000+ часов теории
- 1000 практических заданий в браузере
- 360 000 студентов
Наши выпускники работают в компаниях:
Юнит-тестирование для чайников
Даже если вы никогда в жизни не думали, что занимаетесь тестированием, вы это делаете. Вы собираете свое приложение, нажимаете кнопку и проверяете, соответствует ли полученный результат вашим ожиданиям. Достаточно часто в приложении можно встретить формочки с кнопкой “Test it” или классы с названием TestController или MyServiceTestClient.

То что вы делаете, называется интеграционным тестированием. Современные приложения достаточно сложны и содержат множество зависимостей. Интеграционное тестирование проверяет, что несколько компонентов системы работают вместе правильно.
Оно выполняет свою задачу, но сложно для автоматизации. Как правило, тесты требуют, чтобы вся или почти вся система была развернута и сконфигурирована на машине, на которой они выполняются. Предположим, что вы разрабатываете web-приложение с UI и веб-сервисами. Минимальная комплектация, которая вам потребуется: браузер, веб-сервер, правильно настроенные веб-сервисы и база данных. На практике все еще сложнее. Разворачивать всё это на билд-сервере и всех машинах разработчиков?
We need to go deeper

Давайте сначала спустимся на предыдущий уровень и убедимся, что наши компоненты работают правильно по-отдельности.
Обратимся к википедии:
Модульное тестирование, или юнит-тестирование (англ. unit testing) — процесс в программировании, позволяющий проверить на корректность отдельные модули исходного кода программы.
Идея состоит в том, чтобы писать тесты для каждой нетривиальной функции или метода. Это позволяет достаточно быстро проверить, не привело ли очередное изменение кода к регрессии, то есть к появлению ошибок в уже оттестированных местах программы, а также облегчает обнаружение и устранение таких ошибок.

Таким образом, юнит-тестирование – это первый бастион на борьбе с багами. За ним еще интеграционное, приемочное и, наконец, ручное тестирование, в том числе «свободный поиск».
Нужно ли все это вам? С моей точки зрения ответ: «не всегда».
Не нужно писать тесты, если
- Вы делаете простой сайт-визитку из 5 статических html-страниц и с одной формой отправки письма. На этом заказчик, скорее всего, успокоится, ничего большего ему не нужно. Здесь нет никакой особенной логики, быстрее просто все проверить «руками»
- Вы занимаетесь рекламным сайтом/простыми флеш-играми или баннерами – сложная верстка/анимация или большой объем статики. Никакой логики нет, только представление
- Вы делаете проект для выставки. Срок – от двух недель до месяца, ваша система – комбинация железа и софта, в начале проекта не до конца известно, что именно должно получиться в конце. Софт будет работать 1-2 дня на выставке
- Вы всегда пишете код без ошибок, обладаете идеальной памятью и даром предвидения. Ваш код настолько крут, что изменяет себя сам, вслед за требованиями клиента. Иногда код объясняет клиенту, что его требования —
говне нужно реализовывать
В первых трех случаях по объективным причинам (сжатые сроки, бюджеты, размытые цели или очень простые требования) вы не получите выигрыша от написания тестов.
Последний случай рассмотрим отдельно. Я знаю только одного такого человека, и если вы не узнали себя на фото ниже, то у меня для вас плохие новости.

Любой долгосрочный проект без надлежащего покрытия тестами обречен рано или поздно быть переписанным с нуля

- Без покрытия тестами. Обычно такие системы сопровождаются спагетти-кодом и уволившимися ведущими разработчиками. Никто в компании не знает, как именно все это работает. Да и что оно в конечном итоге должно делать, сотрудники представляют весьма отдаленно.
- С тестами, которые никто не запускает и не поддерживает. Тесты в системе есть, но что они тестируют, и какой от них ожидается результат, неизвестно. Ситуация уже лучше. Присутствует какая-никакая архитектура, есть понимание, что такое слабая связанность. Можно отыскать некоторые документы. Скорее всего, в компании еще работает главный разработчик системы, который держит в голове особенности и хитросплетения кода.
- С серьезным покрытием. Все тесты проходят. Если тесты в проекте действительно запускаются, то их много. Гораздо больше, чем в системах из предыдущей группы. И теперь каждый из них – атомарный: один тест проверяет только одну вещь. Тест является спецификацией метода класса, контрактом: какие входные параметры ожидает этот метод, и что остальные компоненты системы ждут от него на выходе. Таких систем гораздо меньше. В них присутствует актуальная спецификация. Текста немного: обычно пара страниц, с описанием основных фич, схем серверов и getting started guide’ом. В этом случае проект не зависит от людей. Разработчики могут приходить и уходить. Система надежно протестирована и сама рассказывает о себе путем тестов.
Проекты первого типа – крепкий орешек, с ними работать тяжелее всего. Обычно их рефакторинг по стоимости равен или превышает переписывание с нуля.
Почему есть проекты второго типа?
Коллеги из ScrumTrek уверяют, что всему виной темная сторона кода и властелин Дарт Автотестиус. Я убежден, что это очень близко к правде. Бездумное написание тестов не только не помогает, но вредит проекту. Если раньше у вас был один некачественный продукт, то написав тесты, не разобравшись в этой теме, вы получите два. И удвоенное время на сопровождение и поддержку.
Для того чтобы темная сторона кода не взяла верх, нужно придерживаться следующих основных правил.
Ваши тесты должны:
- Быть достоверными
- Не зависеть от окружения, на котором они выполняются
- Легко поддерживаться
- Легко читаться и быть простыми для понимания (даже новый разработчик должен понять что именно тестируется)
- Соблюдать единую конвенцию именования
- Запускаться регулярно в автоматическом режиме
Выберите логическое расположение тестов в вашей VCS
Только так. Ваши тесты должны быть частью контроля версий. В зависимости от типа вашего решения, они могут быть организованы по-разному. Общая рекомендация: если приложение монолитное, положите все тесты в папку Tests; если у вас много разных компонентов, храните тесты в папке каждого компонента.
Выберите способ именования проектов с тестами
Одна из лучших практик: добавьте к каждому проекту его собственный тестовый проект.
У вас есть части системы .Core, .Bl и .Web? Добавьте еще .Core.Tests, .Bl.Tests и .Web.Tests.
У такого способа именования есть дополнительный сайд-эффект. Вы сможете использовать паттерн *.Tests.dll для запуска тестов на билд-сервере.
Используйте такой же способ именования для тестовых классов
У вас есть класс ProblemResolver? Добавьте в тестовый проект ProblemResolverTests. Каждый тестирующий класс должен тестировать только одну сущность. Иначе вы очень быстро скатитесь в унылое го во второй тип проектов (с тестами, которые никто не запускает).
Выберите «говорящий» способ именования методов тестирующих классов
TestLogin – не самое лучшее название метода. Что именно тестируется? Каковы входные параметры? Могут ли возникать ошибки и исключительные ситуации?
На мой взгляд, лучший способ именования методов такой: [Тестируемый метод]_[Сценарий]_[Ожидаемое поведение].
Предположим, что у нас есть класс Calculator, а у него есть метод Sum, который (привет, Кэп!) должен складывать два числа.
В этом случае наш тестирующий класс будет выглядеть так:
сlass CalculatorTests < public void Sum_2Plus5_7Returned() < // … >>
Такая запись понятна без объяснений. Это спецификация к вашему коду.
Выберите тестовый фреймворк, который подходит вам
Вне зависимости от платформы не стоит писать велосипеды. Я видел много проектов, в которых автоматические тесты (в основном, не юнит, а приемочные) запускались из консольного приложения. Не надо этого делать, все уже сделано за вас.
Уделите чуть больше внимания обзору фреймворков. Например, многие .NET разработчики используют MsTest только потому, что он входит в поставку студии. Мне гораздо больше по душе NUnit. Он не создает лишних папок с результатами тестов и имеет поддержку параметризированного тестирования. Я могу так же легко запускать мои тесты на NUnit с помощью Решарпера. Кому-то понравится элегантность xUnit’а: конструктор вместо атрибутов инициализации, реализация IDisposable как TearDown.
Что тестировать, а что – нет?

Одни говорят о необходимости покрытия кода на 100%, другие считают это лишней тратой ресурсов.
Мне нравится такой подход: расчертите лист бумаги по оси X и Y, где X – алгоритмическая сложность, а Y – количество зависимостей. Ваш код можно разделить на 4 группы.
Рассмотрим сначала экстремальные случаи: простой код без зависимостей и сложный код с большим количеством зависимостей.
- Простой код без зависимостей. Скорее всего здесь и так все ясно. Его можно не тестировать.
- Сложный код с большим количеством зависимостей. Хм, если у вас есть такой код, тут пахнет God Object’ом и сильной связностью. Скорее всего, неплохо будет провести рефакторинг. Мы не станем покрывать этот код юнит-тестами, потому что перепишем его, а значит, у нас изменятся сигнатуры методов и появятся новые классы. Так зачем писать тесты, которые придется выбросить? Хочу оговориться, что для проведения такого рода рефакторинга нам все же нужно тестирование, но лучше воспользоваться более высокоуровневыми приемочными тестами. Мы рассмотрим этот случай отдельно.
- Cложный код без зависимостей. Это некие алгоритмы или бизнес-логика. Отлично, это важные части системы, тестируем их.
- Не очень сложный код с зависимостями. Этот код связывает между собой разные компоненты. Тесты важны, чтобы уточнить, как именно должно происходить взаимодействие. Причина потери Mars Climate Orbiter 23 сентября 1999 года заключалась в программно-человеческой ошибке: одно подразделение проекта считало «в дюймах», а другое – «в метрах», и прояснили это уже после потери аппарата. Результат мог быть другим, если бы команды протестировали «швы» приложения.
Придерживайтесь единого стиля написания тела теста
Отлично зарекомендовал себя подход AAA (arrange, act, assert) . Вернемся к примеру с калькулятором:
class CalculatorTests < public void Sum_2Plus5_7Returned() < // arrange var calc = new Calculator(); // act var res = calc.Sum(2,5); // assert Assert.AreEqual(7, res); >>
Такая форма записи гораздо легче читается, чем
class CalculatorTests < public void Sum_2Plus5_7Returned() < Assert.AreEqual(7, new Calculator().sum(2,5)); >>
А значит, этот код проще поддерживать.
Тестируйте одну вещь за один раз
Каждый тест должен проверять только одну вещь. Если процесс слишком сложен (например, покупка в интернет магазине), разделите его на несколько частей и протестируйте их отдельно.
Если вы не будете придерживаться этого правила, ваши тесты станут нечитаемыми, и вскоре вам окажется очень сложно их поддерживать.
Борьба с зависимостями
До сих пор мы тестировали калькулятор. У него совсем нет зависимостей. В современных бизнес-приложениях количество таких классов, к сожалению, мало.
Рассмотрим такой пример.
public class AccountManagementController : BaseAdministrationController < #region Vars private readonly IOrderManager _orderManager; private readonly IAccountData _accountData; private readonly IUserManager _userManager; private readonly FilterParam _disabledAccountsFilter; #endregion public AccountManagementController() < _oms = OrderManagerFactory.GetOrderManager(); _accountData = _ orderManager.GetComponent(); _userManager = UserManagerFactory.Get(); _disabledAccountsFilter = new FilterParam("Enabled", Expression.Eq, true); > >
Фабрика в этом примере берет данные о конкретной реализации AccountData из файла конфигурации, что нас абсолютно не устраивает. Мы же не хотим поддерживать зоопарк файлов *.config. Более того, настоящие реализации могут зависеть от базы данных. Если мы продолжим в том же духе, то перестанем тестировать только методы контроллера и начнем вместе с ними тестировать другие компоненты системы. Как мы помним, это называется интеграционным тестированием.
Чтобы не тестировать все вместе, мы подсунем фальшивую реализацию (fake).
Перепишем наш класс так:
public class AccountManagementController : BaseAdministrationController < #region Vars private readonly IOrderManager _oms; private readonly IAccountData _accountData; private readonly IUserManager _userManager; private readonly FilterParam _disabledAccountsFilter; #endregion public AccountManagementController() < _oms = OrderManagerFactory.GetOrderManager(); _accountData = _oms.GetComponent(); _userManager = UserManagerFactory.Get(); _disabledAccountsFilter = new FilterParam("Enabled", Expression.Eq, true); > /// /// For testability /// /// /// public AccountManagementController( IAccountData accountData, IUserManager userManager) < _accountData = accountData; _userManager = userManager; _disabledAccountsFilter = new FilterParam("Enabled", Expression.Eq, true); >>
Теперь у контроллера появилась новая точка входа, и мы можем передать туда другие реализации интерфейсов.
Fakes: stubs & mocks
Мы переписали класс и теперь можем подсунуть контроллеру другие реализации зависимостей, которые не станут лезть в базу, смотреть конфиги и т.д. Словом, будут делать только то, что от них требуется. Разделяем и властвуем. Настоящие реализации мы должны протестировать отдельно в своих собственных тестовых классах. Сейчас мы тестируем только контроллер.
Выделяют два типа подделок: стабы (stubs) и моки (mock).
Часто эти понятия путают. Разница в том, что стаб ничего не проверяет, а лишь имитирует заданное состояние. А мок – это объект, у которого есть ожидания. Например, что данный метод класса должен быть вызван определенное число раз. Иными словами, ваш тест никогда не сломается из-за «стаба», а вот из-за мока может.
С технической точки зрения это значит, что используя стабы в Assert мы проверяем состояние тестируемого класса или результат выполненного метода. При использовании мока мы проверяем, соответствуют ли ожидания мока поведению тестируемого класса.
Стаб
[Test] public void LogIn_ExisingUser_HashReturned() < // Arrange OrderProcessor = Mock.Of(); OrderData = Mock.Of(); LayoutManager = Mock.Of(); NewsProvider = Mock.Of(); Service = new IosService( UserManager, AccountData, OrderProcessor, OrderData, LayoutManager, NewsProvider); // Act var hash = Service.LogIn("ValidUser", "Password"); // Assert Assert.That(!string.IsNullOrEmpty(hash)); >
Мок
[Test] public void Create_AddAccountToSpecificUser_AccountCreatedAndAddedToUser() < // Arrange var account = Mock.Of(); // Act _controller.Create(1, account); // Assert _accountData.Verify(m => m.CreateAccount(It.IsAny()), Times.Exactly(1)); _accountData.Verify(m => m.AddAccountToUser(It.IsAny(), It.IsAny()), Times.Once()); >
Тестирование состояния и тестирование поведения
Почему важно понимать, казалось бы, незначительную разницу между моками и стабами? Давайте представим, что нам нужно протестировать автоматическую систему полива. Можно подойти к этой задаче двумя способами:
Тестирование состояния
Запускаем цикл (12 часов). И через 12 часов проверяем, хорошо ли политы растения, достаточно ли воды, каково состояние почвы и т.д.
Тестирование взаимодействия
Установим датчики, которые будут засекать, когда полив начался и закончился, и сколько воды поступило из системы.
Стабы используются при тестировании состояния, а моки – взаимодействия. Лучше использовать не более одного мока на тест. Иначе с высокой вероятностью вы нарушите принцип «тестировать только одну вещь». При этом в одном тесте может быть сколько угодно стабов или же мок и стабы.
Изоляционные фреймвоки
- Велосипеды уже написаны до нас
- Многие интерфейсы не так просто реализовать с полпинка
- Наши самописные подделки могут содержать ошибки
- Это дополнительный код, который придется поддерживать
В примере выше я использовал фреймворк Moq для создания моков и стабов. Довольно распространен фреймворк Rhino Mocks. Оба фреймворка — бесплатные. На мой взгляд, они практически эквивалентны, но Moq субъективно удобнее.
На рынке есть также два коммерческих фреймворка: TypeMock Isolator и Microsoft Moles. На мой взгляд они обладают чрезмерными возможностями подменять невиртуальные и статические методы. Хотя при работе с унаследованным кодом это и может быть полезно, ниже я опишу, почему все-таки не советую заниматься подобными вещами.
Шоукейсы перечисленных изоляционных фреймворков можно посмотреть тут. А информацию по техническим аспектам работы с ними легко найти на Хабре.
Тестируемая архитектура
Вернемся к примеру с контроллером.
public AccountManagementController( IAccountData accountData, IUserManager userManager)
Здесь мы отделались «малой кровью». К сожалению, не всегда все бывает так просто. Давайте рассмотрим основные случаи, как мы можем внедрить зависимости:
Инъекция в конструктор
Добавляем дополнительный конструктор или заменяем текущий (зависит от того, как вы создаете объекты в вашем приложении, используете ли IOC-контейнер). Этим подходом мы воспользовались в примере выше.
Инъекция в фабрику
Setter можно дополнительно «спрятать» от основного приложения, если выделить интерфейс IUserManagerFactory и работать в продакшн-коде по интерфейсной ссылке.
public class UserManagerFactory < private IUserManager _instance; /// /// Get UserManager instance /// /// IUserManager with configuration from the configuration file public IUserManager Get() < return _instance ?? Get(UserConfigurationSection.GetSection()); >private IUserManager Get(UserConfigurationSection config) < return _instance ?? (_instance = Create(config)); >/// /// For testing purposes only! /// /// public void Set(IUserManager userManager) < _instance = userManager; >>
Подмена фабрики
Вы можете подменить всю фабрику целиком. Это потребует выделение интерфейса или создание виртуальной функции, создание объектов. После этого вы сможете переопределить фабричные методы так, чтобы они возвращали ваши подделки.
Переопределение локального фабричного метода
Если зависимости инстанцируются прямо в коде явным образом, то самый простой путь – выделить фабричный protected-метод CreateObjectName() и переопределить его в классе-наследнике. После этого тестируйте класс-наследник, а не ваш первоначально тестируемый класс.
Например, мы решили написать расширяемый калькулятор (со сложными действиями) и начали выделять новый слой абстракции.
public class Calculator < public double Multipy(double a, double b) < var multiplier = new Multiplier(); return multiplier.Execute(a, b); >> public interface IArithmetic < double Execute(double a, double b); >public class Multiplier : IArithmetic < public double Execute(double a, double b) < return a * b; >>
Мы не хотим тестировать класс Multiplier, для него будет отдельный тест. Перепишем код так:
public class Calculator < public double Multipy(double a, double b) < var multiplier = CreateMultiplier(); return multiplier.Execute(a, b); >protected virtual IArithmetic CreateMultiplier() < var multiplier = new Multiplier(); return multiplier; >> public class CalculatorUnderTest : Calculator < protected override IArithmetic CreateMultiplier() < return new FakeMultiplier(); >> public class FakeMultiplier : IArithmetic < public double Execute(double a, double b) < return 5; >>
Код намеренно упрощен, чтобы акцентировать внимание именно на иллюстрации способа. В реальной жизни вместо калькулятора, скорее всего, будут DataProvider’ы, UserManager’ы и другие сущности с гораздо более сложной логикой.
Тестируемая архитектура VS OOP
Многие разработчики начинают жаловаться, дескать «этот ваш тестируемый дизайн» нарушает инкапсуляцию, открывает слишком много. Я думаю, что существует только две причины, когда это может вас беспокоить:
Серьезные требования к безопасности
Это значит, что у вас серьезная криптография, бинарники упакованы, и все обвешано сертификатами.
Даже если так, скорее всего, вы сможете найти компромиссное решение. Например, в .NET вы можете использовать internal-методы и атрибут [InternalsVisibleTo], чтобы дать доступ к тестируемым методам из ваших тестовых сборок.
Производительность
Существует ряд задач, когда архитектурой приходится жертвовать в угоду производительности, и для кого-то это становится поводом отказаться от тестирования. В моей практике докинуть сервер/проапгрейдить железо всегда было дешевле, чем писать нетестируемый код. Если у вас есть критический участок, вероятно, стоит переписать его на более низком уровне. Ваше приложение на C#? Возможно, есть смысл собрать одну неуправляемую сборку на С++.
- Мыслите интерфейсами, а не классами, тогда вы всегда сможете легко подменять настоящие реализации подделками в тестовом коде
- Избегайте прямого инстанцирования объектов внутри методов с логикой. Используйте фабрики или dependency injection. В этом случае использование IOC-контейнера в проекте может сильно упростить вам работу.
- Избегайте прямого вызова статических методов
- Избегайте конструкторов, которые содержат логику: вам сложно будет это протестировать.
Работа с унаследованным кодом
Под «унаследованным» мы будем понимать код без тестов. Качество такого кода может быть разным. Несколько советов, как можно покрыть его тестами.
Архитектура тестируема
Нам повезло, прямых созданий классов и мясорубки нет, а принципы SOLID соблюдаются. Нет ничего проще – создаем тестовые проекты, и шаг за шагом покрываем приложение, используя принципы, описанные в статье. В крайнем случае, нам придется добавить пару сеттеров для фабрик и выделить несколько интерфейсов.
Архитектура не тестируема
У нас есть жесткие связи, костыли и прочие радости жизни. Нам предстоит рефакторинг. Как правильно проводить комплексный рефакторинг – тема, выходящая далеко за рамки этой статьи.
Стоит выделить основное правило. Если вы не меняете интерфейсов – все просто, методика идентична. А вот если вы задумали большие перемены, следует составить граф зависимостей и разбить ваш код на отдельные более мелкие подсистемы (надеюсь, что это возможно). В идеале должно получиться примерно так: ядро, модуль #1, модуль #2 и т.д.
После этого выберите жертву. Только не начинайте с ядра. Возьмите сначала что-то поменьше: то, что вы способны отрефакторить за разумное время. Покрывайте эту подсистему интеграционными и/или приемочными тестами. А когда закончите, сможете покрыть эту часть юнит-тестами. Рано или поздно, шаг за шагом, вы должны преуспеть.
Будьте готовы, что сделать это быстро скорее всего не получится. Вам придется проявить волевые качества.
Поддержка тестов

Не относитесь к своим тестам как к второсортному коду. Многие начинающие разработчики ошибочно полагают, что DRY, KISS и все остальное – это для продакшна. А в тестах допустимо все. Это не верно. Тесты – такой-же код. Разница только в том, что у тестов другая цель – обеспечить качество вашего приложения. Все принципы, применямые в разработке продакшн-кода могут и должны применяться при написании тестов.
Есть всего три причины, почему тест перестал проходить:
- Ошибка в продакшн-коде: это баг, его нужно завести в баг-трекере и починить.
- Баг в тесте: видимо, продакшн-код изменился, а тест написан с ошибкой (например, тестирует слишком много или не то, что было нужно). Возможно, что раньше он проходил ошибочно. Разберитесь и почините тест.
- Смена требований. Если требования изменились слишком сильно – тест должен упасть. Это правильно и нормально. Вам нужно разобраться с новыми требованиями и исправить тест. Или удалить, если он больше не актуален.
Уделяйте внимание поддержке ваших тестов, чините их вовремя, удаляйте дубликаты, выделяйте базовые классы и развивайте API тестов. Можно завести шаблонные базовые тестовые классы, которые обязывают реализовать набор тестов (например CRUD). Если делать это регулярно, то вскоре это не будет занимать много времени.
Как «измерить» прогресс
Для измерения успешности внедрения юнит-тестов в вашем проекте следует использовать две метрики:
- Количество багов в новых релизах (в т.ч. и регрессии)
- Покрытие кода
Первая показывает, есть ли у наших действий результат, или мы впустую расходуем время, которое могли бы потратить на фичи. Вторая – как много нам еще предстоит сделать.
- NCover
- dotTrace
- встроенный в студию Test Coverage
Test First?

Я умышленно не касался этой темы до самого конца. С моей точки зрения Test First – хорошая практика, обладающая рядом неоспоримых преимуществ. Однако, по тем или иным причинам, иногда я отступаю от этого правила и пишу тесты после того, как готов код.
На мой взгляд, «как писать тесты» гораздо важнее, чем «когда это делать». Делайте, как вам удобно, но не забывайте: если вы начинаете с тестов, то получаете архитектуру «в придачу». Если вы сначала пишете код, вам возможно, придется его менять, чтобы сделать тестируемым.
Почитать на тему
Отличную подборку ссылок и книг по теме можно найти в этой статье на Хабре. Особенно рекомендую книгу The Art of Unit Testing. Я читал первое издание. Оказывается, вышло уже и второе.
