Русский
Русский
English
Статистика
Реклама

Юнит-тесты

Перевод Не используйте фикстуры в Cypress и юнит-тесты используйте фабричные функции

02.02.2021 18:09:11 | Автор: admin

Для будущих учащихся на курсе JavaScript QA Engineer и всех интересующихся темой автоматизацией тестирования подготовили перевод полезной статьи.

Также приглашаем принять участие в открытом вебинаре на тему Что нужно знать о JS тестировщику. На занятии участники вместе с экспертом рассмотрят особенности JS, которые нужно держать в голове при написании тестов.


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

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

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

Аспекты тестов, которых мы хотим избежать

  1. Высокое зацепление

  2. Отсутствие типобезопасности (что приводит к длительному рефакторингу и ошибкам)

  3. Огромные папки фикстур

Факторные функции все это исправят.

Так что же такое фабричные функции?

Фабричная функция это функция, которая создает объект. Вот так просто. Да, существует шаблон абстрактная фабрика, популяризированный книгой "Gang Of Four's Design Pattern" несколько десятилетий назад. Давайте сделаем функцию красивой и простой.

Сделаем функцию, которая упрощает процесс создания, чтобы было легче тестировать.

Вот самый простой пример в мире:

interface ISomeObj {  percentage: string;}export const makeSomeObj = () => {  return {    percentage: Math.random()  };}

Посмотрим, как такой простой шаблон может быть использован для исправления аспектов нестабильных тестов, описанных выше.

Мы начнем с описания того, как обычно пишутся тесты, а затем будем разрабатывать решение итеративно по мере решения каждой из задач.

Пример того, как проводятся нестабильные тесты в реальном мире

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

Чтобы проиллюстрировать это, давайте рассмотрим пример кода для бизнеса и кода для автоматизации тестирования. Для большинства этих примеров мы предположим, что вы работаете в страховой компании, которая объясняет, как работают правила в каждом штате США.

// This file is "src/pages/newYorkInfo.tsx"import * as React from 'react';interface IUser {    state: string;    address: string;    isAdmin: boolean;    deleted: boolean | undefined;}export const NewYorkUserPage: React.FunctionComponent<{ user: IUser }> = props => {    if (props.user.state === 'NY' && !props.user.deleted) {        const welcomeMessage = `Welcome`;        return <h1 id="ny-dashboard">{welcomeMessage}</h1>;    } else {        return <div>ACCESS DENIED</div>;    }};

Код выглядит неплохо, так что давайте напишем JSON для хранения положительного тестового примера.

// fixtures/user.json{    state: 'NY',    isAdmin: true,    address: '55 Main St',}

А теперь тестовый код. Я продемонстрирую проблему, используя какой-нибудь psuedo-код для теста Cypress, но вы можете представить себе, что это происходит с любым тестовым кодом, в котором вы загружаете фикстуры и запускаете тестовое утверждение.

// When the UI calls the user endpoint, return the JSON as the mocked return valuecy.route('GET', '/user/**', 'fixture:user.json');cy.visit('/dashboard');cy.get('#ny-dashboard').should('exist')

Выглядит неплохо, и он отлично работает, пока вам не понадобится протестировать другой сценарий с участием другого пользователя. Что вы тогда сделаете?

Плохое решение если один файл заработал, продолжайте создавать JSON-файлы

Стоит ли просто создать еще один JSON-файл фикстуры? К сожалению, это простое решение возникает постоянно, потому что оно самое простое (поначалу). Но с увеличением количества случаев, растет и количество JSON-файлов. Вам понадобится 52 различных JSON-файла, чтобы протестировать каждую страницу для каждого пользователя в США. Когда вы начнете тестирование, если пользователь является или не является администратором, вам придется создать 104 файла. Это много файлов!

Но у вас все равно есть проблема типобезопасности. Допустим, Product Owner приходит в команду и говорит: Давайте будем отображать имя пользователя, когда мы его приветствуем.

Таким образом, вы добавляете свойство name в интерфейс и обновляете пользовательский интерфейс для работы в этом примере.

// This file is "src/pages/newYorkInfo.tsx"import * as React from 'react';interface IUser {    name: string;    state: string;    address: string;    isAdmin: boolean;    deleted: boolean | undefined;}export const NewYorkUserPage: React.FunctionComponent<{ user: IUser }> = props => {    if (props.user.state === 'NY' && !props.user.deleted) {        const welcomeMessage = `Welcome ${props.user.name.toLowerCase()}!`;        return <h1 id="ny-dashboard">{welcomeMessage}</h1>;    } else {        return <div>ACCESS DENIED</div>;    }};

Здорово, что вы обновили код для бизнеса, но фикстура JSON устарела. А так как у фикстуры JSON нет свойства name, вы получаете следующую ошибку:

Uncaught TypeError: Cannot read property 'toLowerCase' of undefined

Теперь вы должны добавить свойство name ко всем 52 пользовательским JSON фикстурам. Это можно решить с помощью Typescript.

Немного лучшее решение: Переместите его в файл TypeScript

Переместив JSON из файла исправления в .ts файл, компилятор Typescript найдет для вас ошибку:

// this file is "testData/users"import {IUser} from 'src/pages/newYorkInfo';// Property 'name' is missing in type '{ state: string; isAdmin: true; address: string; deleted: false; }' but required in type 'IUser'.ts(2741)export const generalUser: IUser = {    state: 'NY',    isAdmin: true,    address: '55 Main St',    deleted: false,};

И мы обновим тестовый код, чтобы использовать этот новый объект.

import { generalUser } from 'testData/users';// When the UI calls the user endpoint, return the JSON as the mocked return valuecy.route('GET', '/user/**', generalUser);cy.visit('/dashboard');cy.get('#ny-dashboard').should('exist')

Спасибо Typescript! Как только вы решите проблему с компилятором, добавив name: 'Bob Smith' в GeneralUser:, код компилируется чисто, а лучше всего то, что ваш тест снова пройдет!

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

Например, что происходит, когда появляется разработчик, который еще новичок в юнит-тестировании. Все, о чем думается, это то, что надо проверить основное свойство, которое включает в себя удаленного пользователя. Поэтому они добавляют deleted: false в объект generalUser.

Бабах! Ваш тест не проходит, и их тест проходит. Вот что значит быть высоко сцепленным.

Поэтому разработчик тратит несколько минут (или часов) на дебаггинг и понимает, что оба теста имеют одни и те же базовые данные. Таким образом, разработчик использует простое (но недальновидное решение) из предыдущих и создает другой объект deletedUser, так что есть 1 объект на тест. Это может быстро выйти из-под контроля я видел файлы тестовых данных длиной 5000 строк.

Здесь можно увидеть, как странно это может выглядеть.

// this file is "testData/users"import {IUser} from 'src/pages/newYorkInfo';export const nonAdminUser: IUser = {    name: 'Bob',    state: 'NY',    isAdmin: false,    address: '55 Main St',    deleted: false,};export const adminUser: IUser = {    name: 'Bob',    state: 'NY',    isAdmin: true,    address: '55 Main St',    deleted: false,};export const deletedAdminUser: IUser = {    name: 'Bob',    state: 'NY',    isAdmin: true,    address: '55 Main St',    deleted: true,};export const deletedNonAdmin: IUser = {    name: 'Bob',    state: 'NY',    isAdmin: false,    address: '55 Main St',    deleted: true,};// and on and on and on again...

Должен быть путь получше.

Хорошее решение: Фабричная Функция

Так как же нам рефакторить огромный файл объектов? Сделаем одну функцию!

// src/factories/userimport faker from 'faker';import {IUser} from 'src/pages/newYorkInfo';export const makeFakeUser = (): IUser => {    return {        name: faker.name.firstName() + ' ' + faker.name.lastName(),        state: faker.address.stateAbbr(),        isAdmin: faker.random.boolean(),        address: faker.address.streetAddress(),        deleted: faker.random.boolean(),    }}

Теперь каждый тест может просто вызвать makeFakeUser(), когда он хочет создать пользователя.

И самое лучшее в этом то, что, делая все случайным в фабричной функции, это показывает, что ни один отдельный тест не принадлежит этой функции. Если тест является особым видом IUser, то позже придется модифицировать его самостоятельно.

И это легко сделать. Давайте представим себе удаленный пользовательский тест, где нас не волнует имя пользователя или что-то в этом роде. Нас волнует только то, что они удалены.

import { makeFakeUser } from 'src/factories/user';import {IUser} from 'src/pages/newYorkInfo';// Arrangeconst randomUser = makeFakeUser();const deletedUser: IUser = { ...randomUser, ...{  deleted: true};cy.route('GET', '/user/**', deletedUser);// Actcy.visit('/dashboard');// Assertcy.find('ACCESS DENIED').should('exist')

Для меня, прелесть этого подхода в том, что он сам себя документирует. Любой, кто смотрит на этот тестовый код, должен понимать, что когда API возвращает удаленного пользователя, мы должны найти "Access Denied" на странице.

Но я думаю, что мы сделаем это еще чище.

Лучшее решение: просто переопределить mergePartially

Выше допускалось использование оператор spread, так как это был небольшой объект. Но это может быть более раздражающим, когда это вложенный объект, как этот:

interface IUser {    userName: string;    preferences: {        lastUpdated?: Date;        favoriteColor?: string;        backupContact?: string;        mailingAddress: {            street: string;            city: string;            state: string;            zipCode: string;        }     }}

Вы не захотите, чтобы сотни таких объектов возникали.

Так что если мы позволим пользователям переопределять только то, что они хотят, мы сможем создать действительно простой и базовый код DRY. Представьте себе, что есть очень специфический тест, в котором должен быть пользователь, живущий на "Main Street".

const userOnMainSt = makeFakeUser({    preferences: {        mailingAddress: {            street: 'Main Street'        }    }});

Ого, нужно было только указать то, что нужно для теста, а не другие 7 свойств. И нам не пришлось хранить одноразовый объект в каком-то огромном тестовом файле. И мы также достигли своих целей.

И как нам улучшить нашу функцию makeFakeUser для поддержки такого рода частичного переопределения?

Посмотрите, насколько легко это делает библиотека mergePartially (полное раскрытие: я сопровождающий mergePartially).

const makeFakeUser = (override?: NestedPartial<IDeepObj>): IDeepObj => {        const seed: IDeepObj = {          userName: 'Bob Smith',          preferences: {            mailingAddress: {              street: faker.address.streetAddress(),              city: faker.address.city(),              state: faker.address.stateAbbr(),              zipCode: faker.address.zipCode(),            },          },        };        return mergePartially.deep(seed, override);      };

Подведение итогов

Спасибо за то, что вы прочитали, как мы перешли от нестабильного и огромного тестового кода к маленькому и независимому.

Я был бы рад услышать от вас, что вы думаете об этом подходе.


Узнать больше о курсе JavaScript QA Engineer.

Зарегистрироваться на открытый вебинар на тему Что нужно знать о JS тестировщику.

Подробнее..

Вам не нужны юнит-тесты

18.12.2020 12:07:33 | Автор: admin

Да, вы не ослышались именно так! В IT-сообществе прочно укоренилось мнение, что все эти тесты вам хоть как-то помогают, но так ли это на самом деле? Вы сами пробовали мыслить критически и анализировать это расхожее мнение? Хипстеры придумывают кучу парадигм TDD, BDD, ПДД, ГИБДД лишь чтобы создать иллюзию бурной деятельности и хоть как-то оправдать свою зарплату. Но задумайтесь, что будет, если вы (либо ваши программисты) начнете все свое время уделять исключительно написанию кода? Для тестирования есть отдельное направление и целые подразделения. Вы же не заставляете программистов писать требования, так? Тогда почему они должны писать тесты? Всех согласных и несогласных прошу проследовать внутрь поста, где я вам наглядно покажу, что юнит (и интеграционные) тесты великое зло!

Откуда вообще пошло тестирование

В стародавние времена никакого тестирования не было в принципе. Не было даже такого направления, что уж и говорить про такие термины, как блочное (модульное) и интеграционное тестирование. А про всякие e2e и, прости господи, пайплайны, я вообще молчу. И все это потому, что тестировать, собственно, было еще нечего. В те годы инженеры-программисты только пытались создать первые ЭВМ.

Как нам всем известно, первые ЭВМ были гигантских размеров, весили десятки тонн и стоили дороже этих ваших Apple MacBook Pro Retina 4k 512mb RAM 1Tb SSD Touch Bar USB Type-C. И в те времена разработчики действительно боялись, что во время работы что-нибудь пойдет не так. Думаю, вам известна история возникновения термина баг (bug) если вдруг нет, то почитайте, это очень интересно. И, так как программисты боялись всего на свете, они и придумали модульное тестирование.

Времена менялись, менялись и ЭВМ. Тестирование тоже менялось. Помимо блочных тестов, возникло также и целое направление, которое впоследствии получило название Quality Assurance.

Но разработчики тоже менялись. В наше время становится смешно от мысли, что кто-то боится запустить программу, потому что от этого может загореться сервер. В 2020 году программисты не боятся запускать свои программы. А если нет страха зачем тестировать?

Современные реалии

Повторю свой вопрос если ваш MacBook (или Xiaomi) не взорвется из-за ошибки в коде, зачем тогда тестировать? Вы просто запускаете и наслаждаетесь результатом. Предвосхищая ваше негодование по поводу дороговизны ошибок для заказчика пускай тестированием занимаются специально обученные люди.

На последнем хочу слегка заострить внимание. В современной разработке основная стоимость кроется не в аппаратном, а в программном обеспечении. И ошибки по-прежнему стоят дорого. Но ответственность за эти ошибки плавно перекочевала с плеч разработчиков на плечи тестировщиков. Как-никак, это они назвали себя Quality Assurance а раз проводишь проверку качества, делай это качественно \_()_/

В конце концов, отдел разработки называется Software Development, а не Unmistakable Development. Мы никому ничего не обещаем.

Хороший программист уверен в себе

Когда вы покрываете свой код юнит-тестами, вы будто заявляете всему миру: Смотрите, я не уверен в том, что оно работает. Будут ли вас за такие мысли уважать более опытные коллеги и начальник? Будет ли вам доверять заказчик?

Просто откройте свой проект и задумайтесь. Вы умный и образованный человек. Вы хороши собой. Зачем вам балласт в виде модульных тестов, которые, ко всему прочему, еще и портят вашу репутацию?

Задание: Прямо сейчас скажите себе Я уверен в качестве своего кода и удалите все юнит-тесты из проекта.

В идеальном случае, вы должны удалить все тесты прямо из ветки master, однако, это может быть запрещено правилами репозитория. В таком случае вам придется делать pull request и убеждать своих старомодных коллег в том, что тесты пережиток прошлого. Однако, как только ваши соратники примкнут к вашему мировоззрению, на проекте воцарится гармония.

Запомните несколько простых постулатов:

  1. Хороший программист не пишет тесты, так как не сомневается в качестве своей работы.

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

  3. Тщетные попытки найти ошибки в вашем коде оставьте тестировщикам.

Тесты отнимают время

Время программистов дорогое. Время тестировщиков дешевое. Какой тогда смысл заставлять программистов писать тесты? Это невыгодно даже с финансовой точки зрения.

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

В конце концов, тестировщики тоже люди, и не стоит лишать их хлеба. Если вы будете сами тестировать свой код, весь отдел QA станет попросту не нужен, и бедолаг уволят. Вспомните, что было во времена промышленного переворота в США когда машины начали заменять людей на производстве, начались самые настоящие бунты.

Поэтому не будьте машиной. Не провоцируйте тестировщиков на поднятие бунта.

Парадигмы запутывают

Unit-testing, Integration Testing, End 2 End, Pipelines, CI, CD что вы еще придумаете, лишь бы не работать? Есть мнение, что когда программист выгорает и начинает прокрастинировать, он идет настраивать пайплайн.

Все эти парадигмы и термины запутывают. Опять же, ваши мозговые ресурсы мгновенно тратятся, когда вы начинаете думать про e2e вместо того, чтобы просто программировать. Все это пугающе отвлекает вас и не дает сосредоточиться на главном на написании кода.

Если кому-то надо настроить CI или CD пускай настраивают сами. Пусть это сделает devops, в конце концов. Если вас будут просить как-либо помочь в настройке, смело отказывайтесь и ссылайтесь на свою занятость наиважнейшими и перво-приоритетными задачами, а именно написанием кода.

Вам не нужно знать ничего лишнего. Иными словами, вы программируете. Тестировщики тестируют. Девопсы ковыряются во всяких скриптах на bash. Менеджеры ну, менеджеры есть менеджеры.

Delivery In Time

Я предлагаю ввести лишь одно простое понятие: DIT Delivery In Time. Это схоже с известной парадигмой ППКБ (Просто Пиши Код Б****), но звучит гораздо современнее и толерантнее. Парадигма ППКБ ставит программистов в центр мироздания и не считается с работой других членов команды. Это, как минимум, неуважительно. В DIT мы верим, что программисты скромные служители, единственной целью которых является написание кода. При всем этом, мы не закрываем глаза на работу других коллег и уважаем их труды. Просто мы считаем, что каждый должен быть занят своим делом: программисты программировать, тестировщики тестировать, и тд. Когда каждый будет делать то, чему обучен, сроки перестанут срываться.

Парадигма DIT предлагает сплошные бонусы заказчикам. Они могут нанять исключительно разработчиков, чтобы те ППКБ (просто писали код), и все их бюджеты будут направлены непосредственно на создание продукта. При желании заказчик может также нанять и тестировщиков. То есть, простите, Quality Assurance инженеров. А может и не нанимать и запустить тестирование в продакшене.

Я однажды слышал один забавный диалог:

Сколько человек сейчас тестирует нашу систему?
Один человек.
Мы только что выкатили ее на прод.
Ну значит, нашу систему тестирует 1000 человек.

И это правильно. Можете платить штатным тестировщикам, а можете нанять тысячи внештатных совершенно бесплатно.

При этом стоит держать в голове, что парадигма DIT гарантирует, что программный код будет написан вовремя, потому что разработчики будут заняты только своей работой. Но если подключать тестировщиков, то сроки сдвинутся как минимум на время их работы. А если они найдут какие-то баги (что, разумеется, маловероятно, ведь хорошие программисты уверены в своей работе), то сроки сдвинутся еще немного.

Совет: Чтобы не срывать сроки и доставлять вовремя лучше нанять разработчиков, а тестированием заниматься на продакшене. Даже если что-то пойдет не так, вы всегда можете возразить, что соблюли сроки, как и было обговорено. А о большем и не договаривались.

Про интеграционное тестирование

С модульным тестированием вроде разобрались, настало время поговорить о тестировании интеграционном. Именно оно отнимает больше всего времени.

Когда-то я был молодым и верил в то, что тесты (юнит, интеграционные, да всякие) несут добро. Хорошо написанные тесты гарантировали отсутствие регрессии, то есть вы могли изменять и рефакторить код без боязни, что вы где-то ошиблись. Выглядит здорово, правда? Делаешь кучу правок, запускаешь тесты и смотришь, допустил ли ты ошибку.

Но теперь я повзрослел. Я зрю в корень проблемы, а не на ее последствия. И корнем проблемы является человек по ту сторону монитора, в то время как ошибки в тестах лишь ее последствия. Если улучшить, прокачать навыки программиста, то проблема решится естественным образом, и любые дополнительные проверки утратят актуальность.

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

Но я отошел от темы. Интеграционные тесты (кстати, е2е тоже) это большие усилия. Вы поднимаете несколько компонентов с полными контекстами, или даже несколько систем, и начинаете пересылку сообщений из одной в другую. Вы продумываете тестовые сценарии, причем меньше положительных и больше отрицательных, чтобы удостовериться в работоспособности кода. Вы начинаете думать как тестировщик и вести себя как тестировщик. Вы на грани.

Понимаете, к чему я клоню? Вы делаете чужую работу и отнимаете чужую зарплату. Вы становитесь машиной. А я уже напоминал, к чему может привести промышленный переворот.

Просто будьте собой

Будьте собой и будьте счастливы. Определите свои ценности и следуйте выбранному курсу. Цель программиста созидать, в то время как цель тестировщика разрушать. Вы не можете создавать что-то новое лишь для того, чтобы это затем разрушить. Ведь тогда вы вступите в конфликт с самим собой, со своей сущностью. Именно поэтому вам не следует писать тесты на свой собственный код.

Просто будьте собой!

В качестве заключения

Если вы дочитали до этого момента и не бросились писать гневный комментарий, то либо вы прекрасно понимаете важность тестов и сразу заметили иронию, либо просто обратили внимание на теги :)

Друзья, это были вредные советы. Однако, навеяны они были реальными историями. Сейчас у меня на рабочих проектах хорошее покрытие, и это действительно сильно облегчает работу. Но однажды, давно, я попал на проект с покрытием бранчей в районе 15%. Мы не вылезали из регрессии. Примерно тогда я начал осознавать всю важность тестов и стал задумываться о том, почему некоторые из нас ими пренебрегают.

А какой процент покрытия в ваших проектах? Дотягивает ли покрытие линий/веток до 80%? Или болтается где-то в районе 30? Если у вас частая регрессия и низкое покрытие вы догадываетесь, что стоит изменить?

Я понимаю, что подобный пост не совсем по тематике Хабра. Но сегодня пятница, к тому же на носу Новый Год, так что давайте немного расслабимся :)

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

Подробнее..

Работа с частичными моками в PHPUnit 10

26.04.2021 16:20:03 | Автор: admin

В этом году должен выйти PHPUnit 10 (релиз планировался на 2 апреля 2021 года, но был отложен). Если посмотреть на список изменений, то бросается в глаза большое количество удалений устаревшего кода. Одним из таких изменений является удаление метода MockBuilder::setMethods(), который активно использовался при работе с частичными моками. Этот метод не рекомендуется использовать с версии 8.0, но тем не менее он описан в документации без каких-либо альтернатив и упоминания о его нежелательности. Если почитать исходники PHPUnit, issues и пул-реквесты на GitHub, то станет понятно, почему так и какие есть альтернативы.

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

Что такое частичные моки?

У программного кода, который мы пишем, чаще всего есть какие-то зависимости.

При написании юнит-тестов мы изолируем эти зависимости, подставляя вместо реальных объектов какие-то заглушки с заранее известным состоянием. Это позволяет проверять работу только одного кусочка кода в один момент времени. Эти заглушки чаще всего реализуются с помощью моков.

Про название "мок"

У этого термина в русском языке есть несколько обозначений: мок, mock-объект, подставной объект, имитация. Я буду пользоваться калькой английского слова mock (мок).

Суть мока заключается в том, что вместо объекта-зависимости вы используете специальный объект, в котором заменены все методы оригинального класса. Для такого объекта можно сконфигурировать результаты, возвращаемые методами, а также добавить проверки на наличие вызовов методов.

PHPUnit содержит встроенный механизм для работы с моками. Одной из его возможностей является создание так называемых частичных моков (partial mocks), когда исходное поведение класса заменяется не полностью, а только для отдельных методов. Такие моки очень удобно использовать, когда вам нужно написать тест, который будет проверять работу конкретного метода и в процессе своей работы вызывать другие методы (которые вы проверять не хотите).

Приведу небольшой пример того, где могут быть полезны такие моки.

Вот код базового класса, реализующий паттерн команда:

abstract class AbstractCommand{    /**     * @throws \PhpUnitMockDemo\CommandException     * @return void     */    abstract protected function execute(): void;    public function run(): bool    {        $success = true;        try {            $this->execute();        } catch (\Exception $e) {            $success = false;            $this->logException($e);        }        return $success;    }    protected function logException(\Exception $e)    {        // Logging    }} 

Реальное поведение команды задаётся в методе execute классов-наследников, а метод run() добавляет общее для всех команд поведение (в данном случае делает код exception safe и логирует ошибки).

Если мы хотим написать тест для метода run, мы можем воспользоваться частичными моками, функционал которых предоставляет класс PHPUnit\Framework\MockObject\MockBuilder, доступ к которому предоставляется через вспомогательные методы класса TestCase (в примере это getMockBuilder и createPartialMock):

use PHPUnit\Framework\TestCase;class AbstractCommandTest extends TestCase{    public function testRunOnSuccess()    {        // Arrange        $command = $this->getMockBuilder(AbstractCommand::class)            ->setMethods(['execute', 'logException'])            ->getMock();        $command->expects($this->once())->method('execute');        $command->expects($this->never())->method('logException');        // Act        $result = $command->run();        // Assert        $this->assertTrue($result, "True result is expected in the success case");    }    public function testRunOnFailure()    {        // Arrange        $runException = new CommandException();        // It's an analogue of $this->getMockBuilder(...)->setMethods([...])->getMock()        $command = $this->createPartialMock(AbstractCommand::class, ['execute', 'logException']);        $command->expects($this->once())            ->method('execute')            ->will($this->throwException($runException));        $command->expects($this->once())            ->method('logException')            ->with($runException);        // Act        $result = $command->run();        // Assert        $this->assertFalse($result, "False result is expected in the failure case");    }} 

Исходный код, результаты прогона тестов

В методе testRunOnSuccess с помощью MockBuilder::setMethods() мы задаём список методов оригинального класса, которые мы заменяем (вызовы которых хотим проверить или результаты которых нужно зафиксировать). Все остальные методы сохраняют свою реализацию из оригинального класса AbstractCommand (и их логику можно тестировать). В testRunOnFailure через метод createPartialMock мы делаем то же самое, но явно.

В этом примере всё достаточно просто: мы задаём мокаемые методы и в тесте проверяем их вызов или невызов через expects. В реальном коде бывают и другие случаи, которые требуют переопределения методов:

  • подготовка или освобождение каких-то ресурсов (например, соединения с базой данных);

  • внешние обращения, которые замедляют тесты и загрязняют окружение (отправка запросов к базе данных, чтение из кеша или запись в него и т. д.);

  • отправка какой-то отладочной информации или статистики.

Часто для таких случаев проверок вызова просто нет (поскольку они не всегда нужны и делают тесты хрупкими при изменениях кода).

Кроме переопределения существующих методов, MockBulder::setMethods() позволяет добавлять в класс мока новые методы, которых нет в оригинальном классе. Это может быть полезно при использовании в тестируемом коде магического метода __call.

Возьмём в качестве примера класс \Predis\Client. Он использует метод __call для обработки передаваемых клиенту команд. При этом во внешнем коде это выглядит как вызов конкретного метода и кажется естественным переопределить в создаваемом моке этот вызываемый в коде метод, а не переопределять __call, вдаваясь в детали реализации.

Пример:

   public function testRedisHandle()    {        if (!class_exists('Redis')) {            $this->markTestSkipped('The redis ext is required to run this test');        }        $redis = $this->createPartialMock('Redis', ['rPush']);        // Redis uses rPush        $redis->expects($this->once())            ->method('rPush')            ->with('key', 'test');        $record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]);        $handler = new RedisHandler($redis, 'key');        $handler->setFormatter(new LineFormatter("%message%"));        $handler->handle($record);    } 

Источник: тест RedisHandlerTest из monolog 2.2.0

Какие проблемы возникают при использовании setMethods?

Двойственное поведение может приводить к проблемам.

Если в моках есть переопределённые методы без expectations, то при их переименовании или удалении тест продолжает проходить (хотя метода уже нет и в его добавлении к моку нет смысла).

Небольшая демонстрация. Давайте добавим в код нашего класса команды измерение времени, которое потребовалось для её выполнения:

--- a/src/AbstractCommand.php+++ b/src/AbstractCommand.php@@ -13,6 +13,7 @@ abstract class AbstractCommand     public function run(): bool     {+        $this->timerStart();         $success = true;         try {             $this->execute();@@ -21,6 +22,7 @@ abstract class AbstractCommand             $this->logException($e);         }+        $this->timerStop();         return $success;     }@@ -28,4 +30,14 @@ abstract class AbstractCommand     {         // Logging     }++    protected function timerStart()+    {+        // Timer implementation+    }++    protected function timerStop()+    {+        // Timer implementation+    } } 

Исходный код

В код тестов добавим в мок новые методы, но не будем проверять вызовы через expectations:

--- a/tests/AbstractCommandTest.php+++ b/tests/AbstractCommandTest.php@@ -11,7 +11,7 @@ class AbstractCommandTest extends TestCase     {         // Arrange         $command = $this->getMockBuilder(AbstractCommand::class)-            ->setMethods(['execute', 'logException'])+            ->setMethods(['execute', 'logException', 'timerStart', 'timerStopt']) // timerStopt is a typo             ->getMock();         $command->expects($this->once())->method('execute');         $command->expects($this->never())->method('logException');

Исходный код, результаты прогона тестов

Если прогнать этот тест в PHPUnit версий 8.5 или 9.5, то он успешно пройдёт без каких-то предупреждений:

PHPUnit 9.5.0 by Sebastian Bergmann and contributors..                                                                   1 / 1 (100%)Time: 00:00.233, Memory: 6.00 MBOK (1 test, 2 assertions) 

Конечно, это совсем простой пример, в который несложно добавить expectations для новых методов. В реальном коде всё может быть сложнее, и мне не раз приходилось натыкаться на несуществующие методы в моках.

Ещё сложнее отслеживать подобные проблемы при использовании MockBuilder::setMethodsExcept, который переопределяет все методы класса, кроме заданных.

Как эта проблема решена в PHPUnit 10?

Начало решению этой проблемы молчаливого переопределения несуществующих методов было положено в 2019 году в пул-реквесте #3687, который вошёл в релиз PHPUnit 8.

В MockBuilder появились два новых метода onlyMethods() и addMethods() которые делят ответственность setMethods() на части. onlyMethods() может только заменять методы, существующие в оригинальном классе, а addMethods() только добавлять новые (которых в оригинальном классе нет).

В том же PHPUnit 8 setMethods был помечен устаревшим и появилось предупреждение при передаче несуществующих методов в TestCase::createPartialMock().

Если взять предыдущий пример с некорректным названием метода и использовать createPartialMock вместо вызовов getMockBuilder(...)->setMethods(...), то тест пройдёт, но появится предупреждение о будущем изменении этого поведения:

createPartialMock() called with method(s) timerStopt that do not existin PhpUnitMockDemo\AbstractCommand. This will not be allowed in future versions of PHPUnit.

К сожалению, это изменение никак не было отражено в документации там по по-прежнему была описана только работа setMethods(), а всё остальное было скрыто в недрах кода и GitHub.

В PHPUnit 10 проблема setMethods() решена радикально: setMethods и setMethodsExcept окончательно удалены. Это означает, что если вы используете их в своих тестах и хотите перейти на новую версию PHPUnit, то вам нужно убрать все использования этих методов и заменить их на onlyMethods и addMethods.

Как мигрировать частичные моки из старых тестов на PHPUnit 10?

В этой части я дам несколько советов о том, как это можно сделать.

Сразу скажу, что для использования этих советов не обязательно ждать выхода PHPUnit 10 и переходить на него. Всё это можно делать в процессе работы с тестами, которые запускаются в PHPUnit 8 или 9.

Везде, где возможно, замените вызовы MockBuilder::setMethods() на onlyMethods()

Это кажется совсем очевидным, но во многих случаях этого будет достаточно. Я рекомендую заменить все вхождения и разбираться с падениями. Частично они могут быть вызваны проблемами, описанными выше (и тогда нужно либо удалить метод из мока, либо использовать его актуальное название), а частично использованием магии в мокаемом классе.

Используйте MockBuilder::addMethods() для классов с магией

Если метод, который вы хотите переопределить в моке, работает через магический метод __call, то используйте MockBuilder::addMethods().

Если раньше для классов с магией вы использовали TestCase::createPartialMock() и это работало, то в PHPUnit 10 это сломается. Теперь createPartialMock умеет заменять только существующие методы мокаемого класса, и нужно заменить использование createPartialMock на getMockBuilder()->addMethods().

Если вы создаёте моки для внешних библиотек, то изучите их изменения или максимально конкретно задавайте версию

В тестах, использующих моки классов из внешних библиотек, всё может быть сложнее из-за того, что там может меняться версия зависимости. Особенно актуально это, если в CI вы используете lowest версии зависимостей вместе со стабильными.

Приведу пример из библиотеки PhpAmqpLib.

Допустим, вам нужен мок для класса \PhpAmqpLib\Channel\AMQPChannel.

В версии 2.4 там был метод __destruct, который отправлял внешний запрос (и поэтому его стоит замокать).

В версии 2.5 этот метод был удалён и мокать его уже не нужно.

Если в composer.json зависимость прописана подобным образом: "php-amqplib/php-amqplib": "~2.4", то обе версии буду подходить (но моки для них нужны разные) и нужно будет смотреть, какая из них используется.

Решать это можно несколькими способами:

  • максимально фиксировать версию библиотеки (например, в приведённом примере можно использовать ~2.4.0 и тогда разница будет только в patch-версиях);

  • завязываться на версию библиотеки или наличие метода (но это плохой способ, так как для этого нужно внимательно изучать изменения кода всех используемых библиотек, да и очень похоже это на какой-то хак);

  • использовать для классов из внешних библиотек полные моки, а не частичные (но это не всегда возможно).

Заключение

Частичные моки очень полезный инструмент для написания модульных тестов. К сожалению, разобраться с их изменениями в документации PHPUnit совсем не просто. Надеюсь, что этой статьёй мне удалось как-то это исправить и сделать вашу миграцию на новую версию немного проще.

См. также

Подробнее..

Аспекты хороших юнит-тестов

02.05.2021 12:15:03 | Автор: admin

Эта статья является конспектом книги Принципы юнит-тестирования.

Давайте для начала перечислим свойства хороших юнит-тестов.

Первое. Интегрированы в цикл разработки. Пользу приносят только те тесты, которые вы активно используете; иначе писать их нет смысла.

Второе. Тестируют только самые важные части вашего кода. Не весь рабочий кодзаслуживает одинакового внимания.

Третье. Дают максимальную защиту от багов с минимальными затратами на сопровождение. Для этого нужно уметь распознавать эффективные тесты и писать их.

Однако распознавание и написание эффективного теста два разных навыка. И для приобретения второго навыка необходимо сначала освоить первый. Далее в этой статье будет показано, как распознать эффективный тест. Также будет рассмотрена пирамида тестирования и тестирование по принципу черного ящика / белого ящика.

Четыре аспекта хороших юнит-тестов

Хороший юнит-тест должен обладать следующими атрибутами: защита от багов, устойчивость к рефакторингу, быстрая обратная связь и простота поддержки.

Эти четыре атрибута фундаментальны. Они могут использоваться для анализалюбых автоматизированных тестов, будь то юнит-, интеграционные или сквозные(end-to-end) тесты.

Начнем с первого атрибута хорошего юнит-теста: защиты от багов. Баг (или регрессия) это программная ошибка. Как правило, такиеошибки возникают после внесения изменений в код.

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

Для оценки того, насколько хорошо тест проявляет себя в отношении защиты отбагов, необходимо принять во внимание, следующее:

  • объем кода, выполняемого тестом;

  • сложность этого кода;

  • важность этого кода с точки зрения бизнес-логики.

Как правило, чем больше кода тест выполняет, тем выше вероятность выявить в нембаг. Само собой, тест также должен иметь актуальныйнабор проверок (assertions).

Важен не только объем кода, но и его сложность и важность с точки зрения бизнес-логики. Код, содержащий сложную бизнес-логику, важнее инфраструктурного кода ошибки в критичной для бизнеса функциональности наносят наибольший ущерб.

Как следствие, тестирование тривиального кода обычно не имеет смысла. Этот кодслишком простой и не содержит сколько-нибудь значительного объема бизнес-логики.

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

Рефакторингом называется модификация существующего кода без изменения его наблюдаемого поведения. Обычно рефакторинг проводится для улучшения нефункциональныххарактеристик кода: читаемости и простоты. Примеры рефакторинга переименованиеметода или выделение фрагмента кода в новый класс.

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

Почему столько внимания уделяется ложным срабатываниям? Потому что они могутиметь серьезные последствия для всего приложения. Цельююнит-тестирования является обеспечение устойчивого роста проекта. Устойчивыйрост становится возможным благодаря тому, что тесты позволяют добавлять новуюфункциональность и проводить регулярный рефакторинг без внесения ошибокв код.

Частые ложные срабатывания могут привести к следующим ситуациям:

  • Если тесты падают без веской причины, они притупляют вашу готовностьреагировать на проблемы в коде. Со временем вы привыкаете к таким сбоями перестаете обращать на них внимание. А это может привести к игнорированиюнастоящих ошибок.

  • Начинаете все меньшеи меньше доверять вашим тестам. Они уже не воспринимаются как что-то, начто вы можете положиться. Отсутствие доверия приводит к уменьшению рефакторинга, так как вы пытаетесь свести к минимуму потенциальные ошибки.

Что приводит к ложному срабатыванию?

Количество ложных срабатываний, выданных тестом, напрямую связано со структурой этого теста. Чем сильнее тест связан с деталями имплементации тестируемойсистемы, тем больше ложных срабатываний он порождает.Уменьшить количество ложных срабатываний можно только одним способом:отвязав тест от деталей имплементации тестируемой системы. Тест должен проверять конечный результат наблюдаемое поведение тестируемой системы, а недействия, которые она совершает для достижения этого результата.

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

Рис. 1 Тест слева связан с наблюдаемым поведением SUT, а не с деталями реализации. Такой тест более устойчив к рефакторингу, чем тест справаРис. 1 Тест слева связан с наблюдаемым поведением SUT, а не с деталями реализации. Такой тест более устойчив к рефакторингу, чем тест справа

Связь между первыми двумя атрибутами

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

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

Ситуация, когда тест проходит, а тестируемая функциональность работает правильно, называется истинным отрицательным срабатыванием: тест правильно определяетсостояние системы (отсутствие в ней ошибок).

Рис. 2 - Отношение между защитой от багов и устойчивостью к рефакторингуРис. 2 - Отношение между защитой от багов и устойчивостью к рефакторингу

Если тест не выявляет ошибку, значит, возникла проблема. Ситуация соответствуетправому верхнему квадранту: ложноотрицательное срабатывание. И именно ее помогает избежать защита от багов. Тесты с хорошейзащитой от багов помогают минимизировать количество ложноотрицательныхсрабатываний ошибок II типа.

С другой стороны, существует симметричная ситуация: функциональность работаетправильно, но тест сообщает об ошибке. Это ложное срабатывание. И с ней помогает устойчивость к рефакторингу.

Количества ложных и ложноотрицательных срабатываний образуют метрику точности теста: чем меньше таких срабатываний, тем точнее тест. Сама метрика точности состоит из двух компонентов:

  • насколько хорошо тест выявляет присутствие ошибок (отсутствие ложноотрицательных срабатываний, сфера защиты от багов);

  • насколько хорошо тест выявляет отсутствие ошибок (отсутствие ложных срабатываний, сфера устойчивости к рефакторингу).

Как видно из формулы на рис. 3, улучшить точностьтеста можно двумя способами. Первый повышение числителя (сигнал), то естьповышение вероятности выявления ошибок. Второй уменьшение знаменателя(шум), то есть уменьшения вероятности ложных срабатываний.

Рис. 3 Формула точности тестаРис. 3 Формула точности теста

Третий и четвертый аспекты: быстрая обратнаясвязь и простота поддержки

Быстрая обратная связь является одним из важнейшихсвойств юнит-теста. Чем быстрее работают тесты, тем больше их можно включитьв проект и тем чаще вы их сможете запускать. В результате чего затраты на исправление этих ошибок уменьшаются почти донуля. С другой стороны, медленные тесты увеличивают время, в течение которогоошибки остаются необнаруженными, что приводит к увеличению затрат на ихисправление.

Простота поддержки оценивает затраты на сопровождение кода. Метрика состоит из двух компонентов:

  • Насколько сложно тест понять. Этот компонент связан с размером теста. Чемменьше кода в тесте, тем проще он читается и проще изменяется при необходимости.

  • Насколько сложно тест запустить. Если тест работает с внепроцессными зависимостями, вам придется тратить время на то, чтобы поддерживать эти зависимости в рабочем состоянии.

В поисках идеального теста

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

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

Возможно ли создать идеальный тест? К сожалению, получить максимальные оценки по всем четырем показателям невозможно. Дело в том, что первые три атрибута защита от багов, устойчивость к рефакторингу и быстрая обратная связь являютсявзаимоисключающими. Невозможно довести их до максимума одновременно: однимиз трех придется пожертвовать для максимизации двух остальных.

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

Первый пример сквозные (end-to-end) тесты. Сквозные тесты рассматривают систему с точки зрения конечного пользователя.Они обычно проходят через все компоненты системы, включая пользовательскийинтерфейс, базу данных и внешние приложения.

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

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

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

Рис. 4 - Тривиальный тест, покрывающий простой фрагмент кодаРис. 4 - Тривиальный тест, покрывающий простой фрагмент кода

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

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

Рис. 5 Места, которые занимают тесты по отношению друг к другуРис. 5 Места, которые занимают тесты по отношению друг к другу

Четвертый атрибут простота поддержки не так сильно связан с первыми тремя, за исключением сквозных (end-to-end) тестов. Сквозные тесты обычно имеют больший размер из-за необходимости подготовки всех зависимостей, к которымимогут обращаться такие тесты. Они также требуют дополнительных усилий дляподдержания этих зависимостей в работоспособном состоянии. Таким образом,сквозные тесты требуют больших затрат на сопровождение.

Выдержать баланс между атрибутами хорошего теста сложно. Тест не может иметьмаксимальных значений в каждой из первых трех категорий; также приходится учитывать аспект простоты поддержки. А значит, вам придется идти на компромиссы.Более того, на компромиссы придется идти так, чтобы ни один конкретный атрибутне оказался равным нулю. Уступки должны быть частичными и стратегическими.

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

Рис. 6 Компромиссы между атрибутами хорошего тестаРис. 6 Компромиссы между атрибутами хорошего теста

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

Компромисс между первыми тремя атрибутами хорошего юнит-теста напоминает теорему CAP. Эта теорема утверждает, что распределенное хранилище данных не можетпредоставить более двух из трех гарантий одновременно: согласованность (consistency) данных, доступность (availability), устойчивость к разделению (partition tolerance).

Сходство является двойным:

1. В CAP вы тоже можете выбрать максимум два атрибута из трех;

2. Устойчивость к разделению в крупномасштабных распределенных системахтакже не является предметом для компромиссов. Большое приложение такоекак, например, веб-сайт Amazon не может работать на одной машине. Вариантс достижением согласованности данных и доступности за счет устойчивостик разделению просто не рассматривается.

Пирамида тестирования

Концепция пирамиды тестирования предписывает определенное соотношениеразных типов тестов в проекте: юнит-тесты, интеграционные тесты, сквозные тесты.

Пирамида тестирования часто изображается состоящей из трех типов тестов. Ширина уровней пирамиды обозначает относительную долю тестов определенного типав проекте. Чем шире уровень, тем больше тестов. Высота уровня показывает, насколько близки эти тесты к эмуляции поведения конечного пользователя. Разные типы тестов в пирамиде выбирают разные компромиссы между быстротойобратной связи и защитой от багов. Тесты более высоких уровней пирамиды отдаютпредпочтение защите от багов, тогда как тесты нижних уровней выводят на первыйплан скорость выполнения.

Рис. 7 - Пирамида тестирования предписывает определенное соотношение юнит-,интеграционных и сквозных тестовРис. 7 - Пирамида тестирования предписывает определенное соотношение юнит-,интеграционных и сквозных тестовРис. 8 - Разные типы тестов в пирамиде принимают разные решения относительно быстрой обратной связи и защиты от баговРис. 8 - Разные типы тестов в пирамиде принимают разные решения относительно быстрой обратной связи и защиты от багов

Точное соотношение между типами тестов будет разным для разных команд и проектов. Но в общем случае должно сохраняться соотношение пирамиды: сквозныетесты составляют меньшинство; юнит-тесты большинство; интеграционные тестылежат где-то в середине.

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

У пирамиды тестирования есть исключения. Юнит-тесты менее полезны в ситуациях, в которых отсутствует алгоритмическаяили бизнес-сложность, они быстро вырождаются в тривиальные тесты. В то жевремя интеграционные тесты полезны даже в таких случаях; каким бы простым кодни был, важно проверить, как он работает в интеграции с другими подсистемами(например, базой данных). В результате в CRUD-приложениях у вас будет меньшеюнит-тестов и больше интеграционных.

Другое исключение из пирамиды тестирования API, обращающиеся к единственной внепроцессной зависимости (например, базе данных). В таких приложенияхлогично задействовать больше сквозных тестов. Так как пользовательский интерфейс отсутствует, сквозные тесты будут выполняться достаточно быстро. Затратына сопровождение тоже будут не особенно велики, потому что вы работаете толькос одной внешней зависимостью базой данных.

Выбор между тестированием по принципу черного ящика и белого ящика

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

Тестирование по принципу белого ящика работает по противоположномупринципу. Этот метод тестирования проверяет внутренние механизмы приложения. Тесты строятся на основе исходного кода, а не на основе требованийили спецификаций.

Рис. 9 - Достоинства и недостатки тестирования по принципу черного ящика и белого ящикаРис. 9 - Достоинства и недостатки тестирования по принципу черного ящика и белого ящика

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

Ссылки на все части

Подробнее..

Я сомневался в юнит-тестах, но

13.10.2020 14:23:08 | Автор: admin
Когда я пишу тест, то часто не уверен, что мой дизайн будет на 100% удачным. И хочу, чтобы он давал гибкость в рефакторинге кода например, чтобы затем изменить класс, не меняя код теста.



Но если у меня стандартная пирамида, внизу которой много юнит-тестов, то не получится ли так, что тесты будут знать не про поведение системы, а про то, какие классы там есть?

Всем привет! Это расшифровка подкаста Между скобок моих интервью с интересными людьми из мира разработки на PHP.


Запись, если вам удобнее слушать. В полной аудиоверсии мы также обсуждаем больше вопросов code coverage.

С Владимиром vyants Янцем мы познакомились на февральском PHP-митапе в Ростове: я рассказывал про свой опыт с асинхронностью, он делал доклад про тесты. С того выступления у меня остались вопросы и в период карантина мы созвонились, чтобы обсудить их.

Сергей Жук, Skyeng: Начнем с главного. Нужны ли юнит-тесты? Не рискую ли я получить слишком большую связность теста и кода? Да и проект наверняка будет меняться, почему бы мне не пойти от вершины пирамиды тестирования, с тех же функциональных тестов?

Владимир Янц, Badoo: Это очень хороший вопрос. Давай начнем с того, нужны ли они в принципе. Может, и правда, только функциональные?

Функциональные тесты классные, но закрывают немного другие потребности. Они позволяют убедиться, что приложение или кусок фичи целиком работает. И у них есть своя проблема цена поддержки, написания и запуска.

Например, у нас на данный момент 20 000 приемочных функциональных тестов. Если запустить их в один поток, они будут бежать несколько дней. Соответственно, нам пришлось придумывать, как получить результаты за минуты: отдельные кластеры, куча машин, большая массивная инфраструктура на поддержку всех этих вещей. А еще ваш тест зависит от окружения: других серверов, того, что лежит в базе данных, кэше. И чтобы его починить, нужно тратить много ресурсов, денег и времени разработчиков.

Чем хорош юнит-тест? Ты можешь протестировать всякие безумные кейсы.


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

Поэтому, отвечая на главный вопрос. Нужны ли хорошие юнит-тесты? Да. Нужны ли плохие? Нет. Один из главных принципов тестирования: тест можно инвестировать в контракт, а не реализацию. Контракт это договоренность, что ваш метод или ваша функция принимает на входе, и что она должна с этим сделать.

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

Сергей Жук, Skyeng: Ок, смотри, у меня какое-то веб-приложение, и я начинаю в нем писать юнит-тесты. Скажем, я знаю, что у этого класса может быть 5 разных input, я меняю реализацию, и просто делаю юнит-тест провайдеру, чтобы не тестить каждый раз. И еще есть какая-то опенсорсная либа тут без юнит-теста тоже никуда. А для каких еще кейсов их нужно и не стоит писать?

Владимир Янц, Badoo: Я бы написал тесты на то, где есть какая-то бизнес-логика: не в базе данных, а именно в PHP-коде. Какие-то хелперы, которые считают что-то и выводят, какие-то бизнес-правила отличные кандидаты для тестирования. Также видел, что пытались тестировать тонкие контроллеры в приложении.

Основное, что должны делать юнит-тесты, спасать чистую функцию.


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

Напротив, если у вас какая-то админка с CRUDом и с большим количеством одинаковых форм, юнит-тесты не очень помогут. Логики мало, тестировать такой код в изоляции от базы данных и окружения проблематично. Даже с точки зрения архитектуры. Здесь лучше взять какие-то тесты более высокого уровня.

Сергей Жук, Skyeng: Когда я начинал, мне казалось, что круто юнит-тестировать максимально детально. Вот у меня есть объект, у него есть какой-то метод и несколько зависимостей (например, модель, которая ходит в базу). Я мокал все. Но со временем понял, что ценность таких тестов нулевая. Они тестируют опечатки в коде и также еще больше связывают тесты с ним. Но я до сих пор общаюсь с теми, кто за такой вот тру-подход. Твоя позиция какова: нужно ли так активно мокать? И в каких кейсах оно того точно стоит?

Владимир Янц, Badoo: В целом, мокать полезно. Но одна из самых вредных конструкций, которых, мне кажется, есть в том же PHPUnit, это ожидания.

Например, плохо, когда люди пытаются сделать так, чтобы метод вызвался с определенным набором параметров и определенное количество раз.

Мы пытаемся проверить тестом не контракт. Хороший тест этим не занимается.


Это делает тесты очень хрупкими. Вы будете тратить время на фикс, а вероятность поймать баг очень мала. В один момент вам надоест. Я видел такое: ребята начинали писать юнит-тесты, делали это неправильно, и в какой-то момент отказывались от инструмента, потому что он не приносил пользы.

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

Если у вас есть какой-то хэлпер, который складывает в модели два числа, вы должны сделать фейк-объект так, чтобы проверить, что хэлпер сложит числа правильно. Но не нужно проверять, сколько раз он дернул эту модель, сходил ли он в свойства или дернул геттер это детали реализации, которые не должны быть предметом тестирования.

Сергей Жук, Skyeng: Вот ты начал про вред ожиданий. Моки про них. Нужны ли тогда вообще моки, если есть фейки и стабы?

Владимир Янц, Badoo: Проблема стабов в том, что они не всегда удобны: ты должен их заранее создать, описать. Это удобно, когда есть объект, который часто переиспользуется ты один раз его написал, а затем во всех тестах, где нужно, используешь.

А для объекта, который ты применяешь в одном-двух тестах, писать стаб очень долго.


Поэтому я активно использую моки как стабы. Можно создать мок, замокать все нужные функции и работать с ними. Я использую их только в таком ключе.

Ну а фейки мой любимый подход. Очень помогает тестировать многие вещи. Например, когда ты хочешь протестировать, что все правильно закэшировал. Для Memcached легко создать фейк-объект, он есть в большинстве стандартных поставок фреймворков. В качестве типизации вы используете интерфейс, а не конечный класс, и ваш фейк может быть безопасно передан в качестве аргумента в любую функцию. А внутри, вместо того, чтобы ходить в сам кэш, вы реализуете функционал кэша внутри объекта, делая массив. Это очень здорово облегчает тестирование, потому что вы можете проверить логику с кэшированием иногда это бывает важно.

Сергей Жук, Skyeng: Смотри, еще одна крайность. Я встречал людей, которые говорят: Ок, а как мне протестировать приватный метод/класс?

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

Сергей Жук, Skyeng: Вот ты говоришь, есть контракт, а остальное детали реализации. Если мы говорим о веб-приложении с точки зрения интерфейса: у него есть контракт, по которому оно общается с юзерами. В то же время мы формируем реквест, отправляем, инспектируем респонс, сайд-эффекты, БД, еще что-то. Если делать юнит-тесты, то можно вынести логику общения с БД в отдельный слой, завести интерфейс для репозитория, сделать отдельную in memory реализацию репозитория для тестов. Но стоит ли оно того?

Владимир Янц, Badoo: У тебя есть приложение, которое должно правильно работать целиком. И юнит-тесты должны прикрыть какие-то важные вещи, у которых есть большая вариативность ответов, и которые легко выделены в чистую функцию. Вот там они должны быть в первую очередь.

Юнит-тесты могут дать самую быструю обратную связь: запустил, через минуту получил результаты. Если нашелся баг, навряд ли все приложение работает правильно, если что-то не так с его частью.

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

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

Сергей Жук, Skyeng: Давай напоследок поговорим о мутационных тестах. Нужны ли они?

Владимир Янц, Badoo: Нужно внедрять мутационные тесты, как только вы задумались о юнит-тестах. Они поднимут из-под ковра все те проблемы, которые так часто обсуждают бесполезное тестирование, coverage ради coverage.

Там не нужно ничего писать дополнительно. Это просто библиотека: она берет coverage, который у вас где-то есть, идет в исходный код и начнет менять операторы кода на противоположные. После каждой такой мутации она прогоняет тесты, которые, согласно code coverage, эту строчку покрывают. Если ваши тесты не упали, они бесполезны. И, наоборот, можно находить строчки, для которых есть потенциальные мутации, но нет coverage.

Внедрить это на ранних этапах ничего не стоит. А пользы много.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru