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

Tdd

Кому с Redux жить хорошо

11.02.2021 14:07:02 | Автор: admin
Приветствую всех любителей хорошей инженерки! Меня зовут Евгений Иваха, я фронтенд-разработчик в команде, занимающейся дев-программой в ManyChat. В рамках дев-программы мы разрабатываем инструменты, позволяющие расширять функциональность ManyChat за счет интеграции со сторонними системами.

Существует мнение, что разработка через тестирование, или по канонам Test Driven Development (TDD) для фронтенда не применима. В данной статье я постараюсь развенчать этот миф и покажу, что это не только возможно, но и очень удобно и приятно.

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



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

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

Поскольку, мы в ManyChat в начале 2020 года полностью перешли на TypeScript, будем писать код сразу с использованием строгой типизации.

Redux


Что такое Redux? Redux это паттерн и библиотека для управления и обновления состоянием приложения с использованием специальных событий, называемых Action. Он предоставляет централизованное хранилище состояния, которое используется во всём приложении с правилами, гарантирующими предсказуемое изменение этого состояния. Если посмотреть на диаграмму потока данных в Redux для приложений на React, мы увидим примерно следующее:



При необходимости изменения состояния, например, при клике на элемент в DOM, вызывается Action creator, который создаёт определенный Action. Этот Action c помощью метода Dispatch отправляется в Store, где он передаётся на обработку в Reducers. Редьюсеры, в свою очередь, на основании текущего состояния и информации, которая находится в экшене, возвращают новое состояние приложения, которое принимает React с помощью Selectors для нового рендера DOM. Более подробно о каждом компоненте Redux будет рассказано ниже по ходу разработки приложения.

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

Задача


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



Воспользуемся шаблоном create-react-app:

npx create-react-app my-app --template typescriptcd my-appnpm start

Запустили, убедились, что приложение работает.



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

Теперь приступим к самому вкусному подключению Redux в наш проект. Т.к. приложение у нас совершенно пустое, мы не знаем структуру стора, оставим его напоследок.

Установим нужные пакеты:
npm i redux react-redux redux-mock-store @types/redux @types/react-redux @types/redux-mock-store  

Actions


Что такое Action? Это обычный Javascript объект, у которого есть обязательное свойство type, в котором содержится, как правило, осознанное имя экшена. Создатели Redux рекомендуют формировать строку для свойства type по шаблону домен/событие. Также в нём может присутствовать дополнительная информация, которая, обычно, складывается в свойство payload. Экшены создаются с помощью Action Creators функций, которые возвращают экшены.

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

Напишем первый тест. Для тестирования используем уже ставший стандартным фреймворк Jest. Для запуска тестов в следящем режиме, достаточно в корне проекта выполнить команду npm test.
// actions/actions.test.tsimport { checkboxClick } from '.'describe('checkboxClick', () => {  it('returns checkboxClick action with action name in payload', () => {    const checkboxName = 'anyCheckbox'    const result = checkboxClick(checkboxName)    expect(result).toEqual({ type: 'checkbox/click', payload: checkboxName })  })})

Здесь мы проверяем, Action Creator вернёт экшн с нужным типом и правильными данными, а именно с названием чекбокса. И больше нам здесь нечего проверять.

Само собой, тест у нас красный (сломанный), т.к. код ещё не написан:



Пора написать код:
// actions/package.json{  "main": "./actions"}// actions/actions.tsexport const checkboxClick = (name: string) => ({ type: 'checkbox/click', payload: name })

Проверяем:



Тест пройден, можем приступить к рефакторингу. Здесь мы видим явное дублирование константы с типом экшена, вынесем её в отдельный модуль.
// actionTypes.tsexport const CHECKBOX_CLICK = 'checkbox/click'

Поправим тест:
// actions/actions.test.tsimport { CHECKBOX_CLICK } from 'actionTypes'import { checkboxClick } from '.'describe('checkboxClick', () => {  it('returns checkboxClick action with action name in payload', () => {    const checkboxName = 'anyCheckbox'    const result = checkboxClick(checkboxName)    expect(result).toEqual({ type: CHECKBOX_CLICK, payload: checkboxName })  })})

Тест не проходит, потому что мы не использовали относительный путь к actionTypes. Чтобы это исправить, добавим в tsconfig.json в секцию compilerOptions следующий параметр "baseUrl": "src". После этого понадобится перезапустить тесты вручную.

Убедимся, что тест позеленел, теперь поправим сам код:
// actions/actions.tsimport { CHECKBOX_CLICK } from 'actionTypes'export const checkboxClick = (name: string) => ({ type: CHECKBOX_CLICK, payload: name })

Ещё раз убеждаемся, что тест проходит, и можем двигаться дальше.

Reducers


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

Хранить состояние чекбоксов (отмечены они или нет) мы будем простым объектом, где ключом будет выступать название чекбокса, а в булевом значении непосредственно его состояние.
{  checkboxName: true}

Приступим. Первый тест будет проверять, что мы получаем исходное состояние, т.е. пустой объект.
// reducers/reducers.test.tsimport { checkboxReducer } from '.'describe('checkboxReducer', () => {  it('creates default state', () => {    const state = checkboxReducer(undefined, { type: 'anyAction' })    expect(state).toEqual({})  })})

Т.к. у нас даже нет файла с редьюсером, тест сломан. Напишем код.
// reducers/package.json{  "main": "./reducers"}// reducers/reducers.tsconst initialState: Record<string, boolean> = {}export const checkboxReducer = (state = initialState, action: { type: string }) => {  return state}

Первый тест редьюсера починили, можем написать новый, который уже проверит, что получим в результате обработки экшена с информацией о нажатом чекбоксе.
// reducers/reducers.test.tsimport { CHECKBOX_CLICK } from 'actionTypes'import { checkboxReducer } from '.'describe('checkboxReducer', () => {  it('creates default state', () => {    const state = checkboxReducer(undefined, { type: 'anyAction' })    expect(state).toEqual({})  })  it('sets checked flag', () => {    const state = checkboxReducer(undefined, { type: CHECKBOX_CLICK, payload: 'anyName' })    expect(state.anyName).toBe(true)  })})

Минимальный код для прохождения данного теста будет выглядеть следующим образом:
// reducers/reducers.tsimport { CHECKBOX_CLICK } from 'actionTypes'const initialState: Record<string, boolean> = {}export const checkboxReducer = (  state = initialState,  action: { type: string; payload?: string },) => {  if (action.type === CHECKBOX_CLICK && action.payload) {    return { ...state, [action.payload]: true }  }  return state}

Мы убедились, что при обработке экшена, в котором содержится имя чекбокса, в state будет состояние о том, что он отмечен. Теперь напишем тест, который проверит обратное поведение, т.е. если чекбокс был отмечен, то отметка должна быть снята, свойство должно получить значение false.
// reducers/reducers.test.ts  it('sets checked flag to false when it was checked', () => {    const state = checkboxReducer({ anyName: true }, { type: CHECKBOX_CLICK, payload: 'anyName' })    expect(state.anyName).toBe(false)  })

Убеждаемся, что тест красный, т.к. у нас всегда устанавливается значение в true, ведь до сего момента у нас не было других требований к коду. Исправим это.
// reducers/reducers.tsimport { CHECKBOX_CLICK } from 'actionTypes'const initialState: Record<string, boolean> = {}export const checkboxReducer = (  state = initialState,  action: { type: string; payload?: string },) => {  if (action.type === CHECKBOX_CLICK && action.payload) {    return { ...state, [action.payload]: !state[action.payload] }  }  return state}

Отлично! Мы описали всю необходимую бизнес-логику в тестах, написали код, который этим тестам удовлетворяет.

Selectors


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

Напишем первый тест для селектора.
// selectors/selectors.test.tsimport { getCheckboxState } from './selectors'describe('getCheckboxState', () => {  const state = {    checkboxes: { anyName: true },  }  it('returns current checkbox state', () => {    const result = getCheckboxState('anyName')(state)    expect(result).toBe(true)  })})

Теперь заставим его позеленеть.

Так как селектор должен знать, откуда извлекать информацию, определим структуру хранения.
// types.tsexport type State = {  checkboxes: Record<string, boolean>}

Теперь напишем код селектора. Здесь используется функция высшего порядка из-за особенностей хука useSelector пакета react-redux, который принимает на вход функцию, принимающую один аргумент текущее состояние стора, а нам требуется сообщить ещё дополнительные параметры название чекбокса.
// selectors/package.json{  "main": "./selectors"}// selectors/selectors.tsimport { State } from 'types'export const getCheckboxState = (name: string) => (state: State) => state.checkboxes[name]

Кажется, мы всё сделали правильно, тест теперь зелёный. Но что произойдёт, если у нас ещё не было информации о состоянии чекбокса? Напишем ещё один тест.
// selectors/selectors.test.ts  it('returns false when checkbox state is undefined', () => {    const result = getCheckboxState('anotherName')(state)    expect(result).toBe(false)  })

Получим вот такую картину:



И это правильно, мы получили на выходе undefined, т.е. state ничего не знает об этом чекбоксе. Исправим код.
// selectors/selectors.tsimport { State } from 'types'export const getCheckboxState = (name: string) => (state: State) => state.checkboxes[name] ?? false

Вот теперь селектор работает, как и требуется.

Store


Давайте теперь создадим сам Store, т.е. специальный объект Redux, в котором хранится состояние приложения.
// store.tsimport { AnyAction, createStore, combineReducers } from 'redux'import { State } from 'types'import { checkboxReducer } from 'reducers'export const createAppStore = (initialState?: State) =>  createStore<State, AnyAction, unknown, unknown>(    combineReducers({      checkboxes: checkboxReducer,    }),    initialState,  )export default createAppStore()

Этот код мы отдельно тестировать не будем, т.к. мы используем стороннюю библиотеку, которая уже протестирована. Но далее мы напишем интеграционный тест, который проверит всю связку React + Redux.

React Components


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

Для более удобной работы мы написали небольшую утилиту для тестов. В ней несколько больше функциональности, чем требуется для нашего первого теста, но далее мы всё это применим. Используем удобную библиотеку react-test-renderer, которая позволяет не производить рендер в настоящий DOM, а получать его JS представление. Установим пакет:
npm i react-test-renderer @types/react-test-renderer

Приступим к написанию тестов на компоненты. Начнём непосредственно с чекбокса.

Checkbox


// components/Checkbox/Checkbox.test.tsximport { create } from 'react-test-renderer'import Checkbox from '.'describe('Checkbox', () => {  it('renders checkbox input', () => {    const checkboxName = 'anyName'    const renderer = create(<Checkbox />)    const element = renderer.root.findByType('input')    expect(element.props.type).toBe('checkbox')  })})

Первый тест компонента проверяет, что внутри Checkbox рендерится стандартный input с типом checkbox.

Сделаем тест зелёным.
// components/Checkbox/package.json{  "main": "Checkbox"}// components/Checkbox/Checkbox.tsximport React from 'react'const Checkbox: React.FC = () => {  return (    <div>      <input type="checkbox" />    </div>  )}export default Checkbox

Отлично, теперь добавим свойство label, содержащее текст для html элемента label, который должен отображаться рядом с чекбоксом.
// components/Checkbox/Checkbox.test.tsxit('renders label', () => {    const labelText = 'anyLabel'    const renderer = create(<Checkbox label={labelText} />)    const element = renderer.root.findByType('label')    expect(element.props.children).toBe(labelText)  })

Заставим тест пройти.
// components/Checkbox/Checkbox.tsxconst Checkbox: React.FC<{ label: string }> = ({ label }) => {  return (    <div>      <input type="checkbox" />      <label>{label}</label>    </div>  )}

Осталась небольшая деталь чекбокс как-то должен себя идентифицировать, кроме того, для корректной работы клика по label, нужно прописать id чекбокса в свойство htmlFor. Напишем тест, проверяющий установку свойства id:
// components/Checkbox/Checkbox.test.tsx  it('sets name prop as input id', () => {    const checkboxName = 'anyCheckbox'    const renderer = create(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('input')    expect(element.props.id).toBe(checkboxName)  })

Убедившись, что он красный, исправим код:
// components/Checkbox/Checkbox.tsxconst Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  return (    <div>      <input id={name} type="checkbox" />      <label>{label}</label>    </div>  )}

Тест зеленый, можем написать ещё один, который проверит установку свойства name в свойство htmlFor элемента label.
// components/Checkbox/Checkbox.test.tsx  it('sets name prop as label htmlFor', () => {    const checkboxName = 'anyCheckbox'    const renderer = create(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('label')    expect(element.props.htmlFor).toBe(checkboxName)  })

Тест красный, нужно снова поправить код.
// components/Checkbox/Checkbox.tsxconst Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  return (    <div>      <input id={name} type="checkbox" />      <label htmlFor={name}>{label}</label>    </div>  )}

Пора бы подключить Store к компоненту. Напишем тест, который покажет, что состояние чекбокса (свойство checked) соответствует тому, что хранится в Store.
// components/Checkbox/Checkbox.test.tsximport { Provider } from 'react-redux'import { create } from 'react-test-renderer'import { createAppStore } from 'store'import Checkbox from '.'// omit old code  it('sets checked flag from store when it`s checked', () => {    const store = createAppStore({ checkboxes: { anyName: true } })    const renderer = create(      <Provider store={store}>        <Checkbox name="anyName" label="anyLabel" />      </Provider>,    )    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(true)  })

Тест пока красный, т.к. компонент ничего не знает о сторе. Заставим тест позеленеть.
// components/Checkbox/Checkbox.tsximport React from 'react'import { useSelector } from 'react-redux'import { getCheckboxState } from 'selectors'const Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  const checked = useSelector(getCheckboxState(name))  return (    <div>      <input id={name} type="checkbox" checked={checked} />      <label htmlFor={name}>{label}</label>    </div>  )}export default Checkbox

Тест пройден. Наконец-то, мы задействовали Redux! Мы использовали ранее написанный селектор getCheckboxState, который вызвали с помощью хука useSelector, получили значение и передали его в свойство checked элемента input. Но сейчас произошла другая проблема сломались остальные тесты на компонент.



Дело в том, что ранее в тестах мы не передавали стор в компонент. Выделим часть с провайдером стора в функцию и перепишем наши тесты.
// components/Checkbox/Checkbox.test.tsximport { ReactElement } from 'react'import { Provider } from 'react-redux'import { create } from 'react-test-renderer'import { createAppStore } from 'store'import { State } from 'types'import Checkbox from '.'export const renderWithRedux = (node: ReactElement, initialState: State = { checkboxes: {} }) => {  const store = createAppStore(initialState)  return create(<Provider store={store}>{node}</Provider>)}describe('Checkbox', () => {  it('renders checkbox input', () => {    const checkboxName = 'anyName'    const renderer = renderWithRedux(<Checkbox />)    const element = renderer.root.findByType('input')    expect(element.props.type).toBe('checkbox')  })  it('renders label', () => {    const labelText = 'anyLabel'    const renderer = renderWithRedux(<Checkbox label={labelText} />)    const element = renderer.root.findByType('label')    expect(element.props.children).toBe(labelText)  })  it('sets name prop as input id', () => {    const checkboxName = 'anyCheckbox'    const renderer = renderWithRedux(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('input')    expect(element.props.id).toBe(checkboxName)  })  it('sets name prop as label htmlFor', () => {    const checkboxName = 'anyCheckbox'    const renderer = renderWithRedux(<Checkbox name={checkboxName} label={'anyLabel'} />)    const element = renderer.root.findByType('label')    expect(element.props.htmlFor).toBe(checkboxName)  })  it('sets checked flag from store when it`s checked', () => {    const initialState = { checkboxes: { anyName: true } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(true)  })})

Функция renderWithRedux выглядит достаточно полезной, вынесем её в отдельный модуль и импортируем в тестах.
// utils.tsximport { ReactElement } from 'react'import { Provider } from 'react-redux'import { create } from 'react-test-renderer'import { Store } from './types'import { createAppStore } from './store'export const renderWithRedux = (node: ReactElement, initialState: Store = { checkboxes: {} }) => {  const store = createAppStore(initialState)  return create(<Provider store={store}>{node}</Provider>)}

В итоге, шапка тестового файла будет выглядеть вот так:
// components/Checkbox/Checkbox.test.tsximport { renderWithRedux } from 'utils'import Checkbox from '.'describe('Checkbox', () => {

Для полной уверенности напишем ещё один тест, который проверит, что checked бывает и false.
// components/Checkbox/Checkbox.test.tsx  it('sets checked flag from store when it`s unchecked', () => {    const initialState = { checkboxes: { anyName: false } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(false)  })

Тест пройден, но у нас теперь появилось два теста с похожими описаниями и почти идентичным кодом, давайте немного модифицируем наши тесты, создав табличный тест. Последние два теста превратятся в один:
// components/Checkbox/Checkbox.test.tsx  test.each`    storedState | state    ${true}     | ${'checked'}    ${false}    | ${'unchecked'}  `('sets checked flag from store when it`s $state', ({ storedState }) => {    const initialState = { checkboxes: { anyName: storedState } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    expect(element.props.checked).toBe(storedState)  })

Так уже лучше. А теперь самое вкусное напишем интеграционный тест, который проверит, что при нажатии на чекбокс, он изменит своё состояние, т.е. свойство checked.
// components/Checkbox/Checkbox.test.tsximport { act } from 'react-test-renderer'// omit old code    it('changes it`s checked state when it`s clicked', () => {    const initialState = { checkboxes: { anyName: false } }    const renderer = renderWithRedux(<Checkbox name="anyName" label="anyLabel" />, initialState)    const element = renderer.root.findByType('input')    act(() => {      element.props.onChange()    })    expect(element.props.checked).toBe(true)  })

Здесь мы воспользовались функцией act, пакета react-test-renderer, выполняя которую, мы убеждаемся в том, что все сайд-эффекты уже произошли и мы можем продолжить проверки. И далее проверяем, что когда будет вызвано событие onChange на нашем чекбоксе, он изменит свойство checked на true. Пока этого не происходит, требуется написать код. Окончательный вариант компонента примет вот такой вид.
// components/Checkbox/Checkbox.tsximport React from 'react'import { useDispatch, useSelector } from 'react-redux'import { getCheckboxState } from 'selectors'import { checkboxClick } from 'actions'const Checkbox: React.FC<{ name: string; label: string }> = ({ name, label }) => {  const dispatch = useDispatch()  const checked = useSelector(getCheckboxState(name))  const handleClick = React.useCallback(() => {    dispatch(checkboxClick(name))  }, [dispatch, name])  return (    <div>      <input id={name} type="checkbox" checked={checked} onChange={handleClick} />      <label htmlFor={name}>{label}</label>    </div>  )}export default Checkbox

В коде мы навесили обработчик на событие change, который отправляет action в store, создаваемый функцией checkboxClick. Как видим, тест позеленел. Не открывая браузера и даже не запуская сборку приложения, мы имеем протестированный компонент с отдельным слоем бизнес-логики, заключенной в Redux.

AgreementSubmitButton


Нам требуется ещё один компонент непосредственно кнопка Submit, создадим его. Конечно, вначале тест:
// components/AgreementSubmitButton/AgreementSubmitButton.test.tsximport { renderWithRedux } from 'utils'import AgreementSubmitButton from '.'describe('AgreementSubmitButton', () => {  it('renders button with label Submit', () => {    const renderer = renderWithRedux(<AgreementSubmitButton />)    const element = renderer.root.findByType('input')    expect(element.props.type).toBe('button')    expect(element.props.value).toBe('Submit')  })})

Теперь заставим тест позеленеть:
// components/AgreementSubmitButton/package.json{  "main": "./AgreementSubmitButton"}// components/AgreementSubmitButton/AgreementSubmitButton.tsximport React from 'react'const AgreementSubmitButton: React.FC = () => {  return <input type="button" value="Submit" />}export default AgreementSubmitButton

Тест зелёный, начало положено. Напишем новый тест, проверяющий зависимость свойства disabled новой кнопки от состояния чекбокса. Т.к. может быть два состояния, вновь используем табличный тест:
// components/AgreementSubmitButton/AgreementSubmitButton.test.tsx  test.each`    checkboxState | disabled | agreementState    ${false}      | ${true}  | ${'not agreed'}    ${true}       | ${false} | ${'agreed'}  `(    'render button with disabled=$disabled when agreement is $agreementState',    ({ checkboxState, disabled }) => {      const initialState = { checkboxes: { agree: checkboxState } }      const renderer = renderWithRedux(<AgreementSubmitButton />, initialState)      const element = renderer.root.findByType('input')      expect(element.props.disabled).toBe(disabled)    },  )

Имеем двойной красный тест, напишем код для прохождения этого теста. Компонент станет выглядеть вот так:
// components/AgreementSubmitButton/AgreementSubmitButton.tsximport React from 'react'import { useSelector } from 'react-redux'import { getCheckboxState } from 'selectors/selectors'const AgreementSubmitButton: React.FC = () => {  const checkboxName = 'agree'  const agreed = useSelector(getCheckboxState(checkboxName))  return <input type="button" value="Submit" disabled={!agreed} />}export default AgreementSubmitButton

Ура, все тесты зелёные!
Следует обратить внимание, что в табличном тесте мы намеренно использовали два различных параметра checkboxState и disabled, хотя может показаться, что достаточно только первого, а в тесте написать вот так expect(element.props.disabled).toBe(!disabled). Но это плохой паттерн закладывать какую-то логику внутри тестов. Вместо этого мы явно описываем входные и выходные параметры. Так же, мы здесь немного ускорились, т.к., фактически написали два теста за раз. Такое допустимо, когда чувствуешь в себе силы и понимаешь, что реализация достаточно очевидная. Когда уровень владения TDD ещё не совершенный, лучше создавать по одному тесту за раз. В нашем случае это писать по одной строчке в таблице.

LicenseAgreement


Оформим нашу работу в то, ради чего мы всё это затевали в форму принятия лицензионного соглашения. Какие имеются требования к форме:
  1. Содержится заголовок и непосредственно текст лицензионного соглашения. Эта часть компонента не требует тестирования.
  2. На форме имеется компонент Checkbox с определенными label и name. Это можно и нужно тестировать.
  3. На форме имеется кнопка AgreementSubmitButton. Это тоже прекрасно поддаётся тестированию.

Приступим, первый тест на то, что на форме есть Checkbox:
// components/LicenseAgreement/LicenseAgreement.test.tsximport { renderWithRedux } from 'utils'import Checkbox from 'components/Checkbox'import LicenseAgreement from '.'jest.mock('components/Checkbox', () => () => null)describe('LicenseAgreement', () => {  it('renders Checkbox with name and label', () => {    const renderer = renderWithRedux(<LicenseAgreement />)    const element = renderer.root.findByType(Checkbox)    expect(element.props.name).toBe('agree')    expect(element.props.label).toBe('Agree')  })})

На что тут стоит обратить внимание мы использовали тестовый дублёр для компонента Checkbox в строчке jest.mock('components/Checkbox', () => () => null). Это делает наш тест изолированным, таким образом он не зависит от реализации Checkbox, возможные ошибки в этом компоненте не повлияют на результат выполнения данного теста. Дополнительно это экономит вычислительные ресурсы и время выполнения тестов. Тест красный, требуется написать правильный код:
// components/LicenseAgreement/package.json{  "main": "./LicenseAgreement"}// src/components/LicenseAgreement/LicenseAgreement.tsximport React from 'react'import Checkbox from 'components/Checkbox'const LicenseAgreement: React.FC = () => {  return (    <div>      <Checkbox name="agree" label="Agree" />    </div>  )}export default LicenseAgreement

Получили зеленый тест, можем написать второй для этого компонента. Файл с тестами изменится:
// components/LicenseAgreement/LicenseAgreement.test.tsximport { renderWithRedux } from 'utils'import Checkbox from 'components/Checkbox'import AgreementSubmitButton from 'components/AgreementSubmitButton'import LicenseAgreement from '.'jest.mock('components/Checkbox', () => () => null)jest.mock('components/AgreementSubmitButton', () => () => null)describe('LicenseAgreement', () => {  it('renders Checkbox with name and label', () => {    const renderer = renderWithRedux(<LicenseAgreement />)    const element = renderer.root.findByType(Checkbox)    expect(element.props.name).toBe('agree')    expect(element.props.label).toBe('Agree')  })  it('renders SubmitAgreementButton', () => {    const renderer = renderWithRedux(<LicenseAgreement />)    expect(() => renderer.root.findByType(AgreementSubmitButton)).not.toThrow()  })})

Чтобы он позеленел, добавим AgreementSubmitButton в компонент:
// src/components/LicenseAgreement/LicenseAgreement.tsximport React from 'react'import Checkbox from 'components/Checkbox'import AgreementSubmitButton from 'components/AgreementSubmitButton'const LicenseAgreement: React.FC = () => {  return (    <div>      <Checkbox name="agree" label="Agree" />      <AgreementSubmitButton />    </div>  )}export default LicenseAgreement

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

Ключ на старт!


Вставим над компонентами сам текст соглашения, далее можем добавлять компонент в приложение. В сгенерированном приложении имеется корневой компонент App, модифицируем его тесты на проверку рендера LicenseAgreement:
// App.test.tsximport { renderWithRedux } from 'utils'import LicenseAgreement from 'components/LicenseAgreement'import App from 'App'jest.mock('components/LicenseAgreement', () => () => null)test('renders LicenseAgreement', () => {  const renderer = renderWithRedux(<App />)  expect(() => renderer.root.findByType(LicenseAgreement)).not.toThrow()})

Заставим тест позеленеть:
// App.tsximport React from 'react'import LicenseAgreement from 'components/LicenseAgreement'const App: React.FC = () => {  return <LicenseAgreement />}export default App

Мы получили зелёный тест, можно, наконец, запустить сборку приложения с помощью npm start. Сборка пройдёт успешно, но в браузере мы увидим следующую картину:



Это говорит о том, что мы не подключили Redux store в само приложение. Сделаем это в файле index.tsx:
// index.tsximport React from 'react'import ReactDOM from 'react-dom'import { Provider } from 'react-redux'import 'index.css'import store from 'store'import App from 'App'ReactDOM.render(  <React.StrictMode>    <Provider store={store}>      <App />    </Provider>  </React.StrictMode>,  document.getElementById('root'),)

Теперь приложение запускается, всё работает, как ожидается, кроме внешнего вида:



Исправим это, поправив вёрстку, и получим конечный результат:



Заключение


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

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

Исходные коды полученного приложения доступны на GitHub.

Дополнительные источники информации:
Подробнее..

Быстрый старт гайд по автоматизированному тестированию для Android-разработчика. JVM

14.12.2020 14:08:42 | Автор: admin

Привет! Меня зовут Сергей Иванов, я ведущий разработчик Android в Redmadrobot. С 2016 использую автотесты различных категорий и успел в этом набить немало шишек. Именно поэтому решил поделиться опытом. Возможно, что кому-то статья поможет систематизировать знания или начать применять эту практику в работе.

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

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

Подробнее расскажу про тесты на JVM, а не про UI-тесты, о которых в последнее время пишут часто. Материал будет хорошей отправной точкой для изучения темы, а также поможет дополнить уже имеющиеся знания.

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

  • базовые понятие автоматизированного тестирования;

  • категории тестов их специфика на Android;

  • как писать тестируемый код;

  • как и какие инструменты использовать для тестирования;

  • как писать полезные и поддерживаемые тесты;

  • что тестировать;

  • как и когда применять методологию Test Driven Development.


При производстве приложений автотесты помогают:

  1. Находить баги на раннем этапе разработки. Это позволяет раньше устранять проблемы, при этом расходуя меньше ресурсов.

  2. Локализовать проблему. Чем более низкоуровневым является тест, тем более точно он способен указать на причину ошибки.

  3. Ускорить разработку. Это вытекает из предыдущих пунктов и из того, что благодаря автотестам разработка разных частей фичи может быть оперативно разделена на несколько разработчиков. Установив контракты между компонентами приложения, разработчик может разработать свой компонент и проверить его корректность при отсутствии остальных (например, при полном отсутствии UI).

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

Но есть и проблемы:

  1. Нужно время на внедрение, написание и поддержку.

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

Важные базовые понятия автоматизированного тестирования

System Under Test (SUT) тестируемая система. В зависимости от типа теста системой могут быть разные сущности (о них подробнее написал в разделе категории тестов).

Для различия уровня тестирования по использованию знаний о SUT существуют понятия:

Black box testing тестирование SUT без знания о деталях его внутреннего устройства.

White box testing тестирование SUT с учётом деталей его внутреннего устройства.

Выделяют также Gray box testing, комбинацию подходов, но ради упрощения он будет опущен.

Для обеспечения базового качества автотестов важно соблюдать некоторые правила написания. Роберт Мартин сформулировал в книге "Clean Code" глобальные принципы F.I.R.S.T.

Fast тесты должны выполняться быстро.

Independent тесты не должны зависеть друг от друга и должны иметь возможность выполняться в любом порядке.

Repeatable тесты должны выполняться с одинаковым результатом независимо от среды выполнения.

Self-validating тесты должны однозначно сообщать о том, успешно их прохождение или нет.

Timely тесты должны создаваться своевременно. Unit-тесты пишутся непосредственно перед кодом продукта.

Структура теста состоит как минимум из двух логических блоков:

  • cовершение действия над SUT,

  • проверка результата действия.

Проверка результата заключается в оценке:

  • состояния SUT или выданного ею результата,

  • cостояний взаимодействующих с SUT объектов,

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

При необходимости также добавляются блоки подготовки и сброса тестового окружения, отчасти связанные с первыми тремя принципам F.I.R.S.T.

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

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

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

Test doubles (Тестовые дублёры) фиктивные объекты, заменяющие реальные объекты, от которых зависит SUT, для достижения целей теста.

Тестовые дублеры позволяют:

  • зафиксировать тестовое окружение, имитируя неважные, нереализованные, нестабильные или медленные внешние объекты (например, БД или сервер),

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

Самая популярная классификация включает 5 видов тестовых дублеров, различных по своим свойствам: Dummy, Fake, Stub, Spy, Mock.

Stub объект, который при вызовах его функций или свойств возвращает предустановленные (hardcoded) результаты, а не выполняет код реального объекта. Если же функция не имеет возвращаемого значения, то вызов просто игнорируется.

Mock объект, позволяющий проверять поведение SUT путём отслеживания обращений к функциям и свойствам объекта: были ли в ходе теста вызваны функции мока, в правильном ли порядке, ожидаемые ли аргументы были в них переданы и т.д. Может также включать функциональность Stub.

Почитать об этих и остальных видах дублеров можно в первоисточнике.

Эта классификация не является стандартом, и в фреймворках для создания тестовых дублёров часто ради удобства API несколько типов обобщают термином Mock. А вот чем они на самом деле будут являться, зависит от их последующей конфигурации и применения в тесте. Например, при использовании фреймворка Mockito, экземпляр тестового дублера может быть создан как Dummy, а потом превращен в Stub и в Mock.

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

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

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

Категории тестов

Есть разные версии категоризации тестов, по разным характеристикам, поэтомусуществует некоторая путаница.

Покажу основные категории уровней тестов, на которых тестируется система, на примере одного из самых распространенных вариантов пирамиды тестирования:

Unit-тесты проверяют корректность работы отдельного unit-а (модуля). Unit-ом (то есть SUT данного типа тестирования) может быть класс, функция или совокупность классов.

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

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

End-to-end-тесты (E2E) интеграционные тесты, которые воздействуют на приложение и проверяют результат его работы через самый высокоуровневый интерфейс (UI), то есть на уровне пользователя. Использование тестовых дублеров на этом уровне исключено, а значит обязательно используются именно реальные сервер, БД и т.д.

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

Вернёмся к категориям. В Android сложность категоризации автотестов усугубляется еще и тем, что они могут работать на JVM или в Instrumentation-среде (эмулятор или реальное устройство). Последние называют инструментальными.

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

JVM Integration tests интеграционные тесты, проверяющие взаимодействие модулей или совокупностей модулей без использования Instrumentation. Характеризуются они высокой скоростью исполнения, сравнимой с Unit-тестами, также выполняющимися на JVM.

Instrumentation Integration non-UI tests интеграционные тесты, исполняемые уже в реальной Android-среде, но без UI.

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

E2E UI tests интеграционные инструментальные UI-тесты без тестовых дублеров только с реальным флоу экранов. Максимально приближены к ручным тестам.

Если Unit-тесты являются сильно завязанными на детали реализации, очень быстро выполняются, относительно легко пишутся и наиболее точно при поломке указывают на причину ошибки, то в случае E2E UI ситуация противоположная. Изменение этих характеристик происходит постепенно от низа к верху пирамиды.

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

UI-тесты

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

Часто они оказываются нестабильны в своём поведении и могут то выполняться, то падать, даже если не вносилось никаких изменений в реализацию (нестабильные тесты называют Flaky). Мало того, UI-тесты могут совершенно по-разному себя вести на разных устройствах, эмуляторах и версиях Android. Когда же UI-тесты являются еще и E2E, добавляется хрупкость и снижается скорость выполнения из-за реальных внешних зависимостей. Причем в случае ошибки найти её причину бывает затруднительно, поскольку проверки в таких тестах осуществляются на уровне состояния UI. В таких ситуациях выгоднее обойтись силами QA-инженеров.

Конечно, UI-тесты способны приносить и весьма существенную пользу. Мобильные приложения имеют свойство разрастаться, и в какой-то момент их ручное регрессионное тестирование выходит за адекватные временные рамки. Тогда часть проверок может быть делегирована E2E UI-тестам, что при удачном исполнении может здорово сократить время тестирования.

Поэтому, для написания UI-тестов желательно иметь разработчиков или QA-инженеров-автоматизаторов, которые будут заниматься именно ими бльшую часть времени.

Unit-тесты

Unit-тесты тоже в определенной мере хрупкие, но уже из-за того, что они больше связаны с деталями реализации, которым свойственно периодически меняться. При сильном изменении реализации SUT и связанных с нею сущностей может потребоваться почти полностью переписать unit-тест. Но unit-тесты стабильны.

Степень хрупкости же можно снизить за счет использования black box-стиля написания даже на этом уровне, когда возможно. Но не следует злоупотреблять применением тестовых дублеров: если уже реализованная сущность имеет тривиальную логику или наличие логики не подразумевается, стоит использовать ее настоящую реализацию.

А заменять дублером следует только то, что действительно необходимо для приемлемой изоляции SUT в конкретном случае. Иногда (но далеко не всегда!) бывает оптимальнее сделать переиспользуемый рукописный дублер, чем конфигурировать его фреймворком для создания дублеров в множестве мест.

Хочу отметить, что какими бы хорошими не были автотесты, полностью отказываться от ручного тестирования нельзя. Человеческий глаз и смекалка пока что незаменимы.

Подытожим

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

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

  • Лучше делать акцент на быстро выполняющиеся тесты. Так, после Unit-тестов рекомендую проверять JVM Integration-тестами интеграцию в том масштабе, который можно комфортно обеспечить без использования Instrumentation от ViewModel до слоя данных.

Дальше я буду говорить преимущественно о тестах на JVM. Но некоторые моменты актуальны и для остальных категорий.

Инструментарий

Раньше для написания JVM-тестов наши разработчики использовали фреймворки Junit 4 и Junit 5, но потом переключились на молодой перспективный Spek 2. Junit 4 нужен для инструментальных тестов с другими фреймворками они не работают.

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

Для создания тестовых дублеров применяем Mockito-Kotlin 2 Mockito 2, адаптированный для Kotlin.

Для стаббинга и мокирования сервера MockWebServer библиотеку от Square, рассчитанную на работу с OkHttp.

Фреймворки PowerMock и Robolectric не используем из соображений скорости выполнения тестов и их надёжности. Кроме того, эти фреймворки поощряют плохо пахнущий код это дополнительные зависимости, без которых вполне можно обойтись. Для этого код должен быть тестируемым.

Дизайн кода

Признаки нетестируемого кода:

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

  • Обилие Android-зависимостей. Они требуют Instrumentation или объемную подготовку среды на JVM с тестовыми дублерами, если их использование вообще возможно (см. прошлый пункт).

  • Наличие явного управления асинхронным и многопоточным поведением. Если результат работы SUT зависит от выполнения асинхронной работы, особенно порученной другому потоку (или нескольким), то не получится просто так гарантировать правильность и стабильность выполнения тестов. Тест может совершить проверки и завершиться раньше, чем асинхронная работа будет выполнена, и результат не будет соответствовать желаемому. При этом принудительное ожидание в тестах (в первую очередь на JVM) плохая практика, поскольку нарушается принцип Fast.

Пример
class ExampleViewModel constructor(val context: Context) : BaseViewModel() {    private lateinit var timer: CountDownTimer    fun onTimeAccepted(seconds: Long) {        val milliseconds = MILLISECONDS.convert(seconds, SECONDS)        // Неявная зависимость, Android-зависимость, запуск асинхронной работы        timer = object : CountDownTimer(milliseconds, 1000L) {            override fun onTick(millisUntilFinished: Long) {                showTimeLeft(millisUntilFinished)            }            override fun onFinish() {                // Неявная зависимость. Вызов статической функции с Android-зависимостью                WorkManager.getInstance(context)                    .cancelUniqueWork(SeriousWorker.NAME)            }        }        timer.start()    }

Как сделать код тестируемым

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

Стремиться к чистоте функций. Это функции, которые:

  1. При одинаковом наборе входных данных возвращают одинаковый результат.

  2. Не имеют побочных эффектов, т.е. не модифицируют внешние переменные (класса, глобальные) и переданные в качестве входных данных параметры.

Пример теста такой функции:

val result = formatter.toUppercase("адвокат")assertThat(result).isEqualTo("АДВОКАТ")

Минимизировать количество Android-зависимостей. Часто прямое использование Android-зависимостей в SUT не является необходимым. Тогда их следует выносить вовне, оперируя в SUT типами, поддерживающимися на JVM.

Самая распространенная Android-зависимость в потенциально тестируемых классах ресурсы, и их выносить из, скажем, ViewModel, ну, совсем не хочется. В таком случае можно внедрить Resources во ViewModel, чтобы стаббить конкретные ресурсы (их id актуальны на JVM) и проверять конкретные значения:

mock<Resources> { on { getString(R.string.error_no_internet) } doReturn "Нет интернета" }

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

interface ResourceProvider {    fun getString(@StringRes res: Int, vararg args: Any): String}class ApplicationResourceProvider(private val resources: Resources) : ResourceProvider {    override fun getString(res: Int, vararg args: Any): String {        return resources.getString(res, *args)    }}class TestResourceProvider : ResourceProvider {    override fun getString(res: Int, vararg args: Any): String = "$res"}

При таком поведении TestResourceProvider по умолчанию правильность строки в ожидаемом результате можно сверять по id ресурса:

val string = TestResourceProvider().getString(R.string.error_no_internet)assertThat(string).isEqualTo(R.string.error_no_internet.toString())

В общем случае лучше вообще не заменять дублерами типы, принадлежащие сторонним библиотекам и фреймворкам. Это может привести к проблемам при обновлении их API. Обезопасить себя можно также с помощью Wrapper. Подробнее ситуация разобрана в статье Dont Mock Types You Dont Own.

Использовать Wrapper-ы для статический функций, управления асинхронным и многопоточным поведением. Существует немало стандартных статических функций или Android-зависимостей в виде таких функций. Если нужно иметь с ними дело, то следует помещать их во Wrapper-ы и внедрять в SUT для последующей подмены.

Это поможет и при работе с асинхронностью и многопоточностью: инкапсулирующий управление ими Wrapper можно заменить тестовым дублером, который позволит проверяемому коду выполняться в одном потоке и синхронно вызвать асинхронный код. Для RxJava и Kotlin Coroutines есть стандартные решения от их авторов.

Дизайн тестов

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

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

Spoiler
public void testSubClassSerializerInvokedForBaseClassFieldsHoldingArrayOfSubClassInstances() {    Gson gson = new GsonBuilder()            .registerTypeAdapter(Base.class, new BaseSerializer())            .registerTypeAdapter(Sub.class, new SubSerializer())            .create();    ClassWithBaseArrayField target = new ClassWithBaseArrayField(new Base[] {new Sub(), new Sub()});    JsonObject json = (JsonObject) gson.toJsonTree(target);    JsonArray array = json.get("base").getAsJsonArray();    for (JsonElement element : array) {        JsonElement serializerKey = element.getAsJsonObject().get(Base.SERIALIZER_KEY);        assertEquals(SubSerializer.NAME, serializerKey.getAsString());    }}

Чтобы достичь желаемого эффекта от тестов, необходимо уделить внимание качеству их дизайна.

Наименование теста и разделение на блоки

Чтобы сделать содержимое теста более читабельным, его следует разделять на блоки соответствующих этапов. Я выбрал BDD-стиль, где есть этапы:

  • Given настройка SUT и среды;

  • When действие, инициирующее работу SUT, результат работы которой нужно проверить;

  • Then проверка результатов на соответствие ожиданиям.

Пример разделения тела теста:

@Testfun `when create - while has 1 interval from beginning of day and ending not in end of day - should return enabled and disabled items`() {    // given    val intervalStart = createDateTime(BEGINNING_OF_DAY)    val intervalEnd = createDateTime("2019-01-01T18:00:00Z")    val intervals = listOf(        ArchiveInterval(startDate = intervalStart, endDate = intervalEnd)    )    // when    val result = progressItemsfactory.createItemsForIntervalsWithinDay(intervals)    // then    val expected = listOf(        SeekBarProgressItem.createEnabled(intervalStart, intervalEnd),        SeekBarProgressItem.createDisabled(intervalEnd, createDateTime(END_OF_DAY))    )    assertThat(result).isEqualTo(expected)}

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

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

Для тестов на Junit применим следующий паттерн именования в простых случаях:

  • when - should

    when аналогично блоку When;

    should аналогично блоку Then.

В более сложных случаях, когда есть дополнительные условия:

  • when - while/and - should , где

    while предусловие до вызова целевой функции SUT;

    and условие после вызова функции SUT.

Пример:

@Testfun `when doesValueSatisfyRegex - while value is incorrect - should return false`() {

Так имя теста написано в виде требования, и в случае падения будет сразу видно, какой сценарий отработал некорректно:

Фреймворк Spek 2 выводит всё это на новый уровень. Он предоставляет из коробки DSL в стиле Gherkin (BDD).

object GetCameraGroupsInteractorTest : Spek({    Feature("Transform cached cameras to groups of cameras") {        ...        Scenario("subscribe while has non-grouped camera and unsorted by groups order cameras") {            ...            Given("non-grouped camera and unsorted by groups order cameras") {                ...            }            When("subscribe") {                ...            }            Then("should return four groups") {                ...            }            ...        }    }})

Блоки Given, When, Then подтесты глобального теста, описанного с помощью блока Scenario. Теперь нет необходимости ставить всё описание в названии, можно просто расположить все части в соответствующих блоках.

Результат выполнения имеет иерархический вид:

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

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

Чтобы добиться схожего разделения и отображения результатов с помощью Junit 5, понадобилось бы написать в тестах много бойлерплейта с аннотациями.

Устранение лишнего кода

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

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

В Spek 2 вместо создания полностью отдельных тестов, если они концептуально относятся к одному сценарию, разделение проверок можно сделать с помощью блоков Then/And внутри Scenario:

...Then("should return four groups") {...}And("they should be alphabetically sorted") {...}And("other group should contain one camera") {...}And("other group should be the last") {...}...

В Junit 4 такой возможности нет. На помощь приходит механизм SoftAssertions из AssertJ, который гарантирует выполнение всех assert в тесте. Например:

// thenassertSoftly {    it.assertThat(capabilityState)        .describedAs("Capability state")        .isInstanceOf(Available::class.java)    it.assertThat((capabilityState as Available).disclaimer)        .describedAs("Disclaimer")        .isNull()}

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

3. Использовать обобщающие конструкции тестового фреймворка для одинаковой настройки окружения, если настройка повторяется для большого количества тестов, находящихся на одном уровне иерархии (например, beforeEachScenario и afterEachScenario в случае Spek 2). Если настройка одинакова для нескольких тестовых файлов, можно использовать Extension для Junit 5, Rule для Junit 4, а для Spek 2 подобного механизма из коробки нет, поэтому нужно обходиться конструкциями before/after.

4. Объемные схожие настройки тестового окружения следует также выносить в отдельную функцию.

5. Использовать статические импорты для повсеместно применяемых функций вроде функций проверок AssertJ и Mockito.

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

Пример генератора
object DeviceGenerator {    fun createDevice(        description: String? = null,        deviceGroups: List<String> = emptyList(),        deviceType: DeviceType = DeviceType.CAMERA,        offset: Int = 0,        id: String = "",        photoUrl: String? = null,        isActive: Boolean = false,        isFavorite: Boolean = false,        isPublic: Boolean = false,        model: String? = null,        vendor: String? = null,        title: String = "",        serialNumber: String = "",        streamData: StreamData? = null    ): Device {        return Device(            description = description,            deviceGroups = deviceGroups,            deviceType = deviceType,            offset = offset,            id = id,            photoUrl = photoUrl,            isActive = isActive,            isFavorite = isFavorite,            isPublic = isPublic,            model = model,            vendor = vendor,            title = title,            serialNumber = serialNumber,            streamData = streamData        )    }}Given("initial favorite camera") {    val devices = listOf(        createDevice(id = deviceId, isFavorite = true)    )    ...}

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

Тесты как документация

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

Для сворачивания и разворачивания всех блоков кода в файле в случае Mac используются комбинации клавиш Shift + + - и Shift + + +, для управления конкретным блоком + - и + + соответственно.

В тестах на Junit 4 можно сделать еще лучше, сгруппировав тесты по регионам, ведь их тоже можно сворачивать.

Пример

В тестах на Spek 2 нет нужды делать разделение тестов по регионам, поскольку их можно хорошо сгруппировать с помощью блоков Scenario и Feature.

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

Наконец пример тестов на Spek 2 в режиме документации

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

Она лучше обычной текстовой, поскольку в отличие от тестов, обычную документацию можно забыть актуализировать. Чем тесты более высокоуровневые, тем более близкими к составленным аналитиком функциональным требованиям будут их названия. Это будет заметно в разделе "JVM Integration Testing".

Параметрические тесты

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

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

В документации Spek 2 не написано о возможности написания параметрических тестов, хотя она есть, и писать их проще, чем в Junit 4 и Junit 5. Для этих целей удобно использовать стиль тестов Specification.

Пример параметрического теста в Speck 2
class OrientationTypeTest : Spek({    describe("Orientation type") {        mapOf(            -1 to Unknown,            -239 to Unknown,            361 to Unknown,            2048 to Unknown,            340 to Portrait,            350 to Portrait,            360 to Portrait,            0 to Portrait,            ...        ).forEach { (tiltAngle, expectedOrientation) ->            describe("get orientation by tilt angle $tiltAngle") {                val result = OrientationType.getOrientation(tiltAngle)                it("return $expectedOrientation type") {                    assertThat(result).isEqualTo(expectedOrientation)                }            }        }    }})

Результат выполнения:

Снижение хрупкости non-UI тестов

Я писал, что степень хрупкости unit-тестов при изменениях исходного кода, обусловленную их привязкой к деталям реализации модуля, можно снизить. Это применимо для всех non-UI тестов.

Написание тестов в стиле White box искушает расширять видимость функций/свойств SUT для проверок или установки состояний. Это простой путь, который влечет за собой не только увеличение хрупкости тестов, но и нарушение инкапсуляции SUT.

Избежать этого помогут правила. Можно сказать, что взаимодействие с SUT будет в стиле Black box.

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

  2. Нужно стараться делать функции чистыми. Об этом я говорил выше.

  3. Проверки в тесте следует осуществлять по возвращаемому значению вызываемой публичной функции, публичным свойствам или, в крайнем случае, по взаимодействию с mock-объектами (с помощью функции verify() и механизма ArgumentCaptor в Mockito)

  4. Делать только необходимые проверки в рамках теста. Например, если в тесте проверяется, что при вызове функции A у SUT происходит вызов функции X у другого класса, то не следует до кучи проверять значения её публичных полей, особо не имеющих отношения к делу, и что у SUT не будет более никаких взаимодействий с другими функциями связанного класса (функция verifyNoMoreInteractions() в Mockito).

  5. Если для проведения определенного теста невозможно привести SUT в требуемое предварительное состояние с помощью аргументов целевой функции, моков/стабов или изменения полей, то следует вызвать другие публичные функции, вызов которых приводит SUT в интересующее состояние в условиях реальной работы приложения. Например, вызвать функции onLoginInputChanged и onPasswordInputChanged для подготовки теста onEnterButtonClick во ViewModel

Существует аннотация-маркер @VisibleForTesting для выделения функций/свойств, модификатор доступа которых расширили для тестирования. Благодаря этому маркеру Lint подскажет разработчику, обратившемуся к функции/свойству в таком месте исходного кода, в котором они на самом деле не должны быть доступны, что видимость функции расширена только для тестирования. Несмотря на возможность использования такого маркера, прибегать к расширению видимости всё равно не рекомендуется.

Тестирование асинхронного кода с RxJava

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

Для тестирования SUT, осуществляющей планирование Rx-операций, нужно произвести замену реализаций Scheduler-ов так, чтобы весь код выполнялся в одном потоке. Также важно иметь в виду, что на JVM нельзя использовать AndroidSchedulers.mainThread().

В большинстве случаев все Scheduler-ы достаточно заменить на Schedulers.trampoline(). В случаях, когда нужен больший контроль над временем события, лучше использовать io.reactivex.schedulers.TestScheduler с его функциями triggerActions(), advanceTimeBy(), advanceTimeTo().

Замену реализаций можно совершить двумя способами:

  • RxPlugins (RxJavaPlugins & RxAndroidPlugins);

  • Подход Schedulers Injection.

Первый способ официальный и может быть применен независимо от того, как спроектирована SUT. Он имеет не самое удачное API и неприятные нюансы работы, усложняющие применение в некоторых ситуациях (например, когда внутри тестового файла в одних тестах нужно использовать Schedulers.trampoline(), а в других TestScheduler).

Суть подхода Schedulers Injection заключается в следующем: экземпляры Scheduler-ов попадают в SUT через конструктор, благодаря чему в тесте они могут быть заменены на иные реализации. Этот подход является очень прозрачным и гибким. Также он останется неизменным независимо от выбранного тестового фреймворка (Junit 4, Junit 5, Spek 2) чего нельзя сказать об RxPlugins, которыми придется в каждом управлять по-своему.

Из минусов Shedulers Injection можно выделить необходимость внедрения дополнительного аргумента в SUT и необходимость использования вместо rx-операторов с Sheduler по умолчанию (таких как delay()) их перегруженные варианты с явным указанием Scheduler.

Есть две неплохие статьи на тему обоих подходов: раз, два. Но там упомянуты не все нюансы RxPlugins.

Я предпочитаю второй подход. Чтобы упростить внедрение и подмену реализаций в тесте, я написал SchedulersProvider:

Реализация и применение SchedulersProvider
interface SchedulersProvider {    fun ui(): Scheduler    fun io(): Scheduler    fun computation(): Scheduler}class SchedulersProviderImpl @Inject constructor() : SchedulersProvider {    override fun ui(): Scheduler = AndroidSchedulers.mainThread()    override fun io(): Scheduler = Schedulers.io()    override fun computation(): Scheduler = Schedulers.computation()}fun <T> Single<T>.scheduleIoToUi(schedulers: SchedulersProvider): Single<T> {    return subscribeOn(schedulers.io()).observeOn(schedulers.ui())}// другие необходимые функции-расширения...

Его применение в коде:

class AuthViewModel(    ...    private val schedulers: SchedulersProvider) : BaseViewModel() {    ...    loginInteractor        .invoke(login, password)        .scheduleIoToUi(schedulers)    ...

А вот и его тестовая реализация с Scheduler-ами по умолчанию, вместо которых при надобности можно передать TestScheduler:

class TestSchedulersProvider(    private val backgroundScheduler: Scheduler = Schedulers.trampoline(),    private val uiScheduler: Scheduler = Schedulers.trampoline()) : SchedulersProvider {    override fun ui(): Scheduler = uiScheduler    override fun io(): Scheduler = backgroundScheduler    override fun computation(): Scheduler = backgroundScheduler}

Применение в тесте:

authViewModel = AuthViewModel(    ...    router = mock(),    schedulers = TestSchedulersProvider(),    loginInteractor = loginInteractor,    ...)

Вообще, RxJava из коробки имеет и другие полезные инструменты для тестирования (TestObserver, TestSubscriber), но они не входят в рамки статьи.

JVM Integration Testing

JVM Integration-тесты проверяют взаимодействие модулей или совокупностей модулей на JVM. Какие именно связки стоит тестировать, зависит от конкретных случаев.

В самых масштабных тестах этого типа проверяется взаимодействие всей цепочки модулей от ViewModel до Http-клиента, поскольку в этом промежутке обычно располагается основная логика, требующая проверки. Обеспечивать работу View на JVM обычно накладно и не имеет большого смысла.

Тест взаимодействует с SUT через ViewModel, инициируя действия и проверяя результат.

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

  • android.content.res.Resources или собственный Wrapper. Обычно достаточно стаба, обеспечивающего исправный возврат строк из ресурсов.

  • androidx.arch.core.executor.TaskExecutor. Требуется в любых тестах на JVM, у которых SUT использует LiveData, поскольку стандартная реализация имеет Android-зависимость. Подробнее можно почитать в этой статье. Google предлагает готовое решение этой проблемы в форме Rule лишь для Junit 4, поэтому для Spek 2 и Junit 5 использую рукописный класс, содержащий код из того самого решения:

object TestLiveDataExecutionController {    fun enableTestMode() {        ArchTaskExecutor.getInstance()            .setDelegate(object : TaskExecutor() {                override fun executeOnDiskIO(runnable: Runnable) = runnable.run()                override fun postToMainThread(runnable: Runnable) = runnable.run()                override fun isMainThread(): Boolean = true            })    }    fun disableTestMode() {        ArchTaskExecutor.getInstance().setDelegate(null)    }}

Соответствующие функции достаточно вызывать перед первым и после последнего теста в тестовом файле. Пример применения в Spek 2:

object DeviceDetailViewModelIntegrationTest : Spek({    beforeGroup { TestLiveDataExecutionController.enableTestMode() }    afterGroup { TestLiveDataExecutionController.disableTestMode() }...
  • Сервер. Для имитации сервера используется MockWebServer от создателей OkHttp. Он позволяет предустанавливать ответы на конкретные запросы, проверять состав запросов, факты их вызова и др.

  • Interceptors с Android-зависимостями. Не следует пренебрегать добавлением интерцепторов в тестовую конфигурацию клиента OkHttp, соблюдая тот же порядок, что и в настоящем клиенте, чтобы серверные запросы и ответы правильно обрабатывались. Однако некоторые интерцепторы могут иметь Android-зависимости их следует подменить. Например, это могут быть интерцепторы логирования. Интерцепторы последовательно передают данные друг другу и эту цепочку нельзя прерывать, поэтому фиктивный интерцептор должен выполнять это минимальное требование:

// StubInterceptorInterceptor { chain ->    return@Interceptor chain.proceed(chain.request().newBuilder().build())}
  • Персистентные хранилища данных (SharedPreferences, Room и т.д.)

Базовая логика управления тестовым сетевым окружением сконцентрирована в классе BaseTestNetworkEnvironment. Он используется на JVM и в Instrumentation. За специфическую конфигурацию под каждую из сред отвечают его классы-наследники: JvmTestNetworkEnvironment и InstrumentationTestNetworkEnvironment.

Сервер запускается при создании экземпляра *NetworkEnvironment до запуска теста и отключается функцией shutdownServer() после завершения теста (в случае Gherkin-стиля Spek 2 до и после Scenario соответственно).

Для удобной настройки ответов на конкретные запросы используется функция dispatchResponses. При необходимости к mockServer можно обратиться напрямую.

Реализация BaseTestNetworkEnvironment
abstract class BaseTestNetworkEnvironment {    companion object {        private const val BASE_URL = "/"        private const val ENDPOINT_TITLE = "Mock server"    }    val mockServer: MockWebServer = MockWebServer().also {         it.startSilently()     }    // класс, специфичный для инфраструктуры проекта    protected val mockNetworkConfig: NetworkConfig    init {        val mockWebServerUrl = mockServer.url(BASE_URL).toString()        mockNetworkConfig = TestNetworkConfigFactory.create(mockWebServerUrl, BASE_URL)    }    /**     * Используется для предустановки фиктивных ответов на конкретные запросы к [MockWebServer].     *     * [pathAndResponsePairs] пара путь запроса - ответ на запрос.     *     * Если [MockWebServer] получит запрос по пути, которого нет среди ключей [pathAndResponsePairs],     * то будет возвращена ошибка [HttpURLConnection.HTTP_NOT_FOUND].     */    fun dispatchResponses(vararg pathAndResponsePairs: Pair<String, MockResponse>) {        val pathAndResponseMap = pathAndResponsePairs.toMap()        val dispatcher = object : Dispatcher() {            override fun dispatch(request: RecordedRequest): MockResponse {                val mockResponse = request.path?.let {                   pathAndResponseMap[it]                 }                return mockResponse ?: mockResponse(HttpURLConnection.HTTP_NOT_FOUND)            }        }        mockServer.dispatcher = dispatcher    }    fun shutdownServer() {        mockServer.shutdown()    }    /**     * Запуск сервера с отключенными логами     */    private fun MockWebServer.startSilently() {        Logger.getLogger(this::class.java.name).level = Level.WARNING        start()    }}

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

Пример реализации JvmTestNetworkEnvironment
// Если не передавать в конструктор класса специфические экземпляры тестовых дублеров, то будут использоваться// стабы с минимальным предустановленным поведением, необходимым для функционирования сетевого флоу.class JvmTestNetworkEnvironment(    val mockPersistentStorage: PersistentStorage = mockPersistentStorageWithMockedAccessToken(),    val mockResources: ResourceProvider = TestResourceProvider()) : BaseTestNetworkEnvironment() {    private val nonAuthZoneApiHolderProvider: NonAuthZoneApiHolderProvider    private val authZoneApiHolderProvider: AuthZoneApiHolderProvider    init {        val moshiFactory = MoshiFactory()        val serverErrorConverter = ServerErrorConverter(moshiFactory, mockResources)        val stubInterceptorProvider = StubInterceptorProvider()        val interceptorFactory = InterceptorFactory(            ErrorInterceptorProvider(serverErrorConverter).get(),            AuthInterceptorProvider(mockPersistentStorage).get(),            stubInterceptorProvider.get(),            stubInterceptorProvider.get()        )        nonAuthZoneApiHolderProvider = NonAuthZoneApiHolderProvider(            interceptorFactory,            moshiFactory,            mockNetworkConfig        )        authZoneApiHolderProvider = AuthZoneApiHolderProvider(            interceptorFactory,            moshiFactory,            UserAuthenticator(),            mockNetworkConfig        )    }    fun provideNonAuthZoneApiHolder() = nonAuthZoneApiHolderProvider.get()    fun provideAuthZoneApiHolder() = authZoneApiHolderProvider.get()}

Функции для упрощения создания серверных ответов:

fun mockResponse(code: Int, body: String): MockResponse = MockResponse().setResponseCode(code).setBody(body)fun mockResponse(code: Int): MockResponse = MockResponse().setResponseCode(code)fun mockSuccessResponse(body: String): MockResponse = MockResponse().setBody(body)

Тела фиктивных серверных ответов сгруппированы по object-ам, соответствующим разным запросам. Это делает тестовые файлы чище и позволяет переиспользовать ответы и значения их полей в разных тестах. Одни и те же ответы используются тестами на JVM и Instrumentation (в том числе UI).

После добавления комментария "language=JSON" IDE подсвечивает синтаксис JSON. Подробнее о Language injections можно почитать тут.

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

Пример object с фиктивными серверными ответами
object LoginResponses {    const val INVALID_CREDENTIALS_ERROR_DESCRIPTION = "Неверный логин или пароль"        fun invalidCredentialsErrorJson(        errorDescription: String = INVALID_CREDENTIALS_ERROR_DESCRIPTION    ): String {        // language=JSON        return """            {              "error": {                "code": "invalid_credentials",                "description": "$errorDescription",                "title": "Введены неверные данные"              }            }            """.trimIndent()    }...}

Схожим образом вынесены и пути запросов:

const val LOGIN_REQUEST_PATH = "/auth/login"object GetCameraRequest {    const val DEVICE_ID = "1337"    const val GET_CAMERA_REQUEST_PATH = "/devices/camera/$DEVICE_ID"}...

Общие для JVM и Instrumentation файлы должны находиться в директории, доступной обоим окружениям. Доступ настраивается в build.gradle:

android {    sourceSets {        // Instrumentation        androidTest {            java.srcDirs += 'src/androidTest/kotlin'            java.srcDirs += 'src/commonTest/kotlin'        }        // JVM        test {            java.srcDirs += 'src/test/kotlin'            java.srcDirs += 'src/commonTest/kotlin'        }    }}

Взаимодействие View и ViewModel построено особым способом, благодаря которому очень удобно писать unit-тесты ViewModel и integration-тесты. Публичные функции ViewModel представляют события со стороны View (обычно они соответствуют действиям со стороны пользователя) и именуются в событийном стиле:

ViewModel воздействует на View посредством двух LiveData:

  • state описание состояния View

  • events однократные события, не сохраняющиеся в state

Этот подход в более удобном виде реализован в нашей библиотеке.

Пример организации ViewModel, ViewState и ViewEvents
class AuthViewModel(...) {    val state = MutableLiveData<AuthViewState>()    val events = EventsQueue<ViewEvent>()    ...}sealed class AuthViewState {    object Loading : AuthViewState()    data class Content(        val login: String = "",        val password: String = "",        val loginFieldState: InputFieldState = Default,        val passwordFieldState: InputFieldState = Default,        val enterButtonState: EnterButtonState = Disabled    ) : AuthViewState() {        sealed class InputFieldState {            object Default : InputFieldState()            object Error : InputFieldState()            object Blocked : InputFieldState()        }...    }}class EventsQueue<T> : MutableLiveData<Queue<T>>() {    fun onNext(value: T) {        val events = getValue() ?: LinkedList()        events.add(value)        setValue(events)    }}// ViewEvents:interface ViewEventdata class ShowSnackbarError(val message: String) : ViewEventclass OpenPlayStoreApp : ViewEvent...
Наконец, пример JVM Integration-теста
object AuthViewModelIntegrationTest : Spek({    Feature("Login") {        // region Fields and functions        lateinit var authViewModel: AuthViewModel        lateinit var networkEnvironment: JvmTestNetworkEnvironment        val login = "log"        val password = "pass"        fun setUpServerScenario() {            networkEnvironment = JvmTestNetworkEnvironment()            val authRepository = networkEnvironment.let {                AuthRepositoryImpl(                    nonAuthApi = it.provideNonAuthZoneApiHolder(),                    authApi = it.provideAuthZoneApiHolder(),                    persistentStorage = it.mockPersistentStorage,                    inMemoryStorage = InMemoryStorage()                )            }            val clientInfo = ClientInfo(...)            val loginInteractor = LoginInteractor(authRepository, clientInfo)            authViewModel = AuthViewModel(                resources = networkEnvironment.mockResources,                schedulers = TestSchedulersProvider(),                loginInteractor = loginInteractor                analytics = mock()            )        }        beforeFeature { TestLiveDataExecutionController.enableTestMode() }        afterFeature { TestLiveDataExecutionController.disableTestMode() }        beforeEachScenario { setUpServerScenario() }        afterEachScenario { networkEnvironment.shutdownServer() }        // endregion        Scenario("input credentials") {...}        Scenario("click enter button and receive invalid_credentials error from server") {            Given("invalid_credentials error on server") {                networkEnvironment.dispatchResponses(                    LOGIN_REQUEST_PATH to mockResponse(HTTP_UNAUTHORIZED, invalidCredentialsErrorJson())                )            }            When("enter not blank credentials") {                authViewModel.onCredentialsChanged(login, password)            }            And("click enter button") {                authViewModel.onEnterButtonClick(login, password)            }            Then("reset password, mark login and password input fields as invalid and disable enter button") {                val state = authViewModel.state.value                val expectedState = Content(                    login = login,                    password = "",                    loginFieldState = Content.InputFieldState.Error,                    passwordFieldState = Content.InputFieldState.Error,                    enterButtonState = Content.EnterButtonState.Disabled                )                assertThat(state).isEqualTo(expectedState)            }            And("create snackbar error event with message from server") {                val expectedEvent = authViewModel.events.value!!.peek()                assertThat(expectedEvent).isEqualTo(ShowSnackbarError(INVALID_CREDENTIALS_ERROR_DESCRIPTION))            }        }        ...    }    ...})

Так тестируется основная логика пользовательских сценариев. Эти сценарии с теми же данными затем могут быть проверены в UI-тестах.

Что в итоге нужно тестировать?

Не нужно тестировать чужие библиотеки это ответственность разработчиков библиотек (исследовательское тестирование исключение). Тестировать нужно свой код.

Unit-тесты следует писать на логику, в которой есть реальная вероятность совершения ошибки. Это могут быть ViewModel, Interactor, Repository, функции форматирования (денег, дат и т.д.) и другие стандартные и нестандартные сущности. Тривиальную логику тестировать не стоит. Но нужно следить за изменением непокрытой тестами логики, если она при очередном изменении перестанет быть тривиальной, то тогда её нужно протестировать.

100%-е покрытие кода тестами несёт с собой вред: трата лишнего времени на написание бесполезных тестов, боль при изменении реализации, при поддержке бесполезных тестов, иллюзия хорошо протестированной системы. Процент покрытия не отражает реальной картины того, насколько хорошо система протестирована.

Я предпочитаю не устанавливать минимальный порог тестового покрытия для нового кода. Однако для разработчика всё же может быть полезной точечная проверка покрытия SUT, над которой он работает.

Тестами нужно проверять не только основные сценарии, но и краевые. Обычно там кроется наибольшее число багов.

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

JVM Integration-тесты от ViewModel до слоя данных следует писать для каждого экрана. Менее масштабные JVM Integration при надобности. Возможны случаи, когда большинство модулей, включая ViewModel, сами по себе являются слишком простыми, чтобы их стоило покрывать unit-тестами. Однако создание масштабного JVM integration-теста на всю цепочку будет очень кстати, тем более что пишутся такие тесты достаточно просто и однотипно.

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

Тесты Instrumentation Integration non-UI только когда нужно проверить что-то, что нельзя адекватно проверить на JVM.

E2E UI- и Component UI-тесты нужны для замены части ручных тестов при регрессионном тестировании. Разумно доверить их написание QA-инженерам. В настоящее время мы с коллегами ищем оптимальный подход к тому, как организовывать UI-тесты, в каком количестве их писать и как сочетать с более низкоуровневыми тестами.

Test Driven Development

Можно подумать, что о написании тестов уже известно достаточно и пора идти в бой, но есть еще один момент Вы, вероятно, собрались написать очередную фичу и затем покрыть её тестами? Замечательная идея. Именно так и стоит делать, пока навык написания тестов не будет более менее отработан. Такой подход называют Test Last. Конечно же, среди пишущих тесты разработчиков он наиболее распространен. Но он имеет серьезные недостатки:

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

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

  • тесты остаются на последнюю очередь и на них зачастую не остается времени.

Решить эти проблемы можно, используя принцип Test First, придуманным Кентом Беком. Он основан на идее: "Never write a single line of code unless you have a failing automated test" (не стоит писать код реализации, пока для него не написан падающий тест).

На базе этого принципа Кент Бек создал методологию Test Driven Development (TDD, разработка через тестирование). Согласно ей, разработка должна вестись итеративно, путем цикличного повторения шагов Red-Green-Refactor (микро-цикл):

  • написать тест на логику, которую предстоит реализовать, и убедиться, что он падает;

  • написать простейшую реализацию, чтобы тест выполнился успешно;

  • провести рефакторинг реализации, не сломав тесты.

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

Позже Роберт Мартин развил TDD, сформулировав Three Laws of TDD (нано-цикл):

  • перед написанием какого-либо кода реализации необходимо написать падающий тест;

  • тест не должен содержать больше, чем нужно для его падения или провала компиляции;

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

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

Со временем Робертом были сформулированы еще два более масштабных цикла. Про всех них можно почитать в его статье.

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

Я несколько отступился от канонов и нашел эффективным такой алгоритм работы при реализации новой фичи:

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

2. Создать SUT, описать его интерфейс.

  • Если функции должны возвращать какой-то результат, можно писать в их теле TODO(), чтобы код мог скомпилироваться, тогда при вызове функции тест будет прерван эксепшеном. Другой вариант хардкодить возврат простого объекта или null. Так тесты смогут совершить проверки после вызова функции, но тут лучше быть поаккуратнее.

fun doSomething(): Boolean { TODO() }

3. Создать тестовый файл для SUT, объявить тесты-требования.

  • Описать столько кейсов, сколько получится. Нормально, если в ходе написания реализации на ум придут еще какие-то кейсы.

    В пустые тесты/блоки можно добавлять вызов функции fail() (из Junit или AssertJ), чтобы не забыть реализовать какой-то из тестов, поскольку пустой тест при запуске выдает положительный результат.

@Testfun `when invoke - should do something`() {    fail { "not implemented" }}

4. Реализовать тест(ы)

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

5. Реализовать SUT, чтобы реализованные тесты успешно выполнились.

  • По умолчанию в момент времени стоит фокусироваться на прохождении одного конкретного теста.

6. Отрефакторить SUT, сохранив успешность выполнения реализованных тестов.

7. Если остались нереализованные тесты, перейти к пункту #4.

Алгоритм доработки SUT, которая уже покрыта тестами:

  1. Объявить новые тесты согласно новым требованиям,

  2. Реализовать новые тесты,

  3. Реализовать доработку в SUT, чтобы новые тесты выполнились успешно

  4. Если старые тесты упали:

    • Они актуальны при новых требованиях исправить реализацию SUT и/или эти тесты,

    • Они неактуальны удалить.

  5. Отрефакторить SUT, сохранив успешность выполнения реализованных тестов,

  6. Если остались нереализованные тесты, перейти к пункту 2.

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

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

По итогу получаем от подхода следующие преимущества:

  • Предварительное написание тестов вынуждает реализовывать SUT заведомо тестируемой. Тесты оказываются слабо связанными с деталями реализации.

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

  • Наличие тестов делает рефакторинг реализации безопасным. После каждого изменения реализации можно быстро прогнать все тесты SUT и в случае обнаружения поломки сразу же её устранить. Время, затрачиваемое на отладку, очень сильно сокращается.

  • На тесты хватает времени, ведь они неотъемлемая часть процесса разработки

  • Все эти факторы в совокупности сокращают время, затрачиваемое на разработку и на развитие приложения в будущем.

  • Приятно видеть, как красные тесты один за другим превращаются в зелёные

TDD это в первую очередь подход к разработке. Методология замечательно показывает себя при реализации SUT с unit- и JVM integration-тестами, поскольку их можно быстро и часто запускать. С Instrumentation non-UI-тестами применять её можно, но из-за длительности запуска придется запускать тесты реже. Применять же TDD с UI-тестами крайне не рекомендуется.

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

Заключение

Применение автоматизированного тестирования способно вывести разработку ПО на качественно новый уровень. Главное подходить к делу осознанно.

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

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

Полезные материалы

Подробнее..

Изучаю Scala Часть 3 Юнит Тесты

21.08.2020 22:11:51 | Автор: admin

Привет, Хабр! Мало написать хороший код. Нужно еще покрыть его хорошими Юнит Тестами. В прошлой статье я сделал простой веб сервер. Теперь попробую написать насколько тестов. Обычных, Property-based и с моками. За подробностями добро пожаловать под кат.

Содержание



Ссылки


Исходники
Образы docker image

И так для юнит тестов нужны 3 либы.

  1. Библиотека для создания тестов
  2. Библиотека которая будет генерировать тестовые данные
  3. Библиотека которая будет создавать моки объектов


Для создания тестов я использовал библиотеку ScalaTest
"org.scalatest" %% "scalatest" % "3.2.0" % Test

Для генерирования тестовых данных для Property-based тестирования я использовал ScalaCheck
"org.scalacheck" %% "scalacheck" % "1.14.3" % Test

и расширение которое совмещает ScalaTest + ScalaCheck ScalaTestPlusScalaCheck
"org.scalatestplus" %% "scalacheck-1-14" % "3.2.0.0" % Test

Для создания моков объектов я использовал ScalaMock
"org.scalamock" %% "scalamock" % "4.4.0" % Test

Простенький класс который представляет собой тип заполненной (не пустой) строки. Его мы сейчас и будем тестировать.
package domain.commonsealed abstract case class FilledStr private(value: String)object FilledStr {  def apply(value: String): Option[FilledStr] = {    val trimmed = value.trim    if (trimmed.nonEmpty) {      Some(new FilledStr(trimmed) {})    } else {      None    }  }}

Создаем класс для наших тестов
class FilledStrTests extends AnyFlatSpec with should.Matchers with ScalaCheckPropertyChecks {}

Создаем метод который будет проверять что при создании нашего класса из одинаковых строк мы будем получать одинаковые данные.
 "equals" should "return true fro equal value" in {    val str = "1234AB"    val a = FilledStr(str).get    val b = FilledStr(str).get    b.equals(a) should be(true)  }

В прошлом тесте мы захрадкодили в ручную созданную строку. Теперь сделаем тест используя сгенерированные данные. Мы будем использовать property-based подход при котором тестируются свойства функции что при вот таких входных данных мы получим вот такие выходные данные.
  "constructor" should "save expected value" in {    forAll { s: String =>//Тут фильтруем тестовые данные. Говорим что мы хотим использовать для теста только заполненные строки.      whenever(s.trim.nonEmpty) {        val a = FilledStr(s).get        a.value should be(s)      }    }  }

Можно явно настроить генератор тестовых данных чтобы использовать только нужные нам данные. Например так:
//Определяем наш набор данныхval evenInts = for (n <- Gen.choose(-1000, 1000)) yield 2 * n//Прогоняем тесты с этим наборомforAll (evenInts) { (n) => n % 2 should equal (0) }

Так же можно не передавать явно наш генератор а определить его implict через Arbitrary чтобы он автоматически передавался в качестве генератора в тесты. Например так:
implicit lazy val myCharArbitrary = Arbitrary(Gen.oneOf('A', 'E', 'I', 'O', 'U'))val validChars: Seq[Char] = List('X')//Это тест будет искать ближайший Arbitrary[Char] и получать данные для теста из него.forAll { c: Char => validChars.contains(c) }

Так же с помощью Arbitrary можно генерировать и сложные объекты.
case class Foo(intValue: Int, charValue: Char)val fooGen = for {  intValue <- Gen.posNum[Int]  charValue <- Gen.alphaChar} yield Foo(intValue, charValue)implicit lazy val myFooArbitrary = Arbitrary(fooGen)forAll { foo: Foo => (foo.intValue < 0) ==  && !foo.charValue.isDigit }

Теперь по пробуем написать тест по серьезней. Будем мокать зависимости для TodosService. Он Использует 2 репозитория и репозиторий в свою очередь использует абстракцию над транзакцией UnitOfWork. Будем тестить его самый простой метод
  def getAll(): F[List[Todo]] =    repo.getAll().commit()

Который просто вызывает репозиторий, начинает в нем транзакцию на чтения списка Todo, завершает ее и возвращает результат. Так же в тесте вместо F[_] поставлена монада Id которая просто возвращает хранящееся в ней значение.
class TodoServiceTests extends AnyFlatSpec with MockFactory with should.Matchers {  "geAll" should "возвращает ожидаемые значения" in {//Создаем моки зависимостей.    implicit val tr = mock[TodosRepositoryContract[Id, Id]]    implicit val ir = mock[InstantsRepositoryContract[Id]]    implicit val uow = mock[UnitOfWorkContract[Id, List[Todo], Id]]//Создаем сервис. Он принимает зависимости в свой конструктор не явно через implicit    val service= new TodosService[Id, Id]()//Создаем Id монаду со списком Todo внутри    val list: Id[List[Todo]] = List(Todo(1, "2", 3, Instant.now()))//Устанавливаем что метод getAll репозитория будет возвращать uow и будет вызван 1 раз    (tr.getAll _).expects().returning(uow).once()//Устанавливаем что метод commit будет возвращать созданную коллекцию и будет вызван 1 раз    (uow.commit _).expects().returning(list).once()//Устанавливаем что результат метода сервиса getAll должен быть равен значению коллекции //которую возвращает наш репозиторий    service.getAll() should be(list)  }}

Тесты писать на Scala оказалось очень даже приятно и ScalaCheck, ScalaTest, ScalaMock оказались очень хорошими библиотеками. Как и библиотека для создания АПИ tapir и библиотека для сервера http4s и библиотека для стримов fs2. Пока что окружение и библотеки для Scala вызывают у меня только положительные эмоции. Надеюсь дальше эта тенденция продолжится.
Подробнее..

Деконструкция TDD

23.09.2020 20:15:33 | Автор: admin

Здравствуйте, меня зовут Дмитрий Карловский. А вы на канале "Core Dump", где мы берём разные темы из компьютерной науки и деконструируем их по полочкам. Начнём мы с разработки через тестирование.


Test Driven Development

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


Этот ритуал сделает ваш код красивым и надёжным. Поддерживать его будет легко и просто. А разработка будет простой и быстрой. Так во всяком случае настоятельно убеждают нас проповедники TDD.


Видео запись этого разбора.


Суть TDD


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


Pure TDD


И тут сразу возникает вопрос вопрос на миллион...


Что делать, когда тест изначально зелёный?


Варианты ответов...


  • Сломать код
  • Удалить тест
  • Это невозможно

Если сломать код, то тесты естественным образом покраснеют. А после того как мы откатим изменение, тесты снова станут зелёными.


Можно удалить тест. Ведь если нет теста, то нет и проблемы с его изначальным цветом.


Наконец, моё любимое: по TDD такого быть не должно. Где-то ты накосячил, что у тебя так получилось. Покайся, грешник.


А теперь, внимание, правильный ответ: все эти варианты это полная беспросветная чушь. Хотя именно один из них, как правило, слышишь от адептов TDD.


Парадокс воронов


Говоря про ломание кода, нельзя не упомянуть про парадокс воронов. Суть его в том, что задаётся вопрос: "Все ли вороны чёрные?". И для ответа на него берутся нечёрные предметы. Например красные яблоки. И каждое такое яблоко как бы подтверждает тезис о том, что "все вороны чёрные", ведь яблоки не чёрные и при этом не вороны. Что-то тут не так с логикой, не правда ли?



Так же как в парадоксе воронов, адепт TDD нередко думает, что падение теста на явно некорректном коде может хоть что-то сказать о том, будет ли тест падать на коде, который похож на корректный. Однако, тут нет никакой связи. Это просто два разных кода.


Поломка и починка кода это не более чем бессмысленное действие, необходимое только для того, чтобы формально пройти 2 обязательных шага ритуального цикла. Но насколько вероятна эта зелёная ситуация?


Изначально зелёные тесты неизбежны


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


  1. R G
  2. R G
  3. R G
  4. G ?
  5. G ?
  6. G ?
  7. G ?
  8. G ?

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


То есть, для обеспечения качества мы вынуждены явно нарушать основную идею TDD: сначала тест, потом код.


Правильный TDD


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


Fixed TDD


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


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


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


В такой форме TDD уже можно применять с пользой. Однако...


TDD приводит к куче лишней работы


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


Давайте рассмотрим типичный сценарий написания простой функции...


Итерация В начале В процесссе В результате
1 R R G
2 GR RR GG
3 GGR RRR GGG
4 GGGR GGRR GGGG
5 GGGGR GGGGR GGGGG
6 GGGGGR RRRRRR GGGGGG

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


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


Однако, всё ещё остаётся довольно высокая вероятность в любой момент столкнуться с такой ситуацией, что очередное изменение кода сломает вообще все тесты. Ну или как минимум большую их часть. А значит мы снова возвращаемся к ситуации "все тесты уже написаны, нужно лишь написать правильно код". А все те осторожные шажки по одному тесту, что мы делали ранее, просто обесцениваются.


Когда TDD полезен


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


  • Исправление дефектов
  • Заранее известный контракт
  • Не заставить себя писать тесты

Если к вам пришёл баг, то вы его можете сперва формализовать в виде теста, а далее исправлять код, пока все тесты не позеленеют.


Кроме того, в ситуации, когда контракт вам известен заранее, вы можете сразу написать все тесты, а потом уже весь код, который им соответствует. Формально, это будет не TDD, так как вы не будете менять код после добавления каждого теста. Однако, это самый что ни на есть Test Driven, так как тесты будут диктовать вам реализацию.


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


Программировать ли по TDD?


Если кто-то вам скажет, что он "программирует по TDD", то можете быть уверены, что он попросту не ведает, что творит. У TDD есть ряд фундаментальных проблем, поэтому его применение оправдано лишь в весьма ограниченном числе случаев. И то, не в той форме в которой ему как правило учат многочисленные коучи.


- Ритуализация :-(- Явно некорректный код :-(- Бесполезная работа :-(- Там, где это уместно :-)- Не зацикливаться :-)

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


Что ещё посмотреть по TDD?


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



Продолжение следует..


  • Лайк
  • Подписка
  • Комментарий
  • Поделись-ка

Если данный разбор показался вам полезным, то дайте мне об этом знать посредством лайка. А так же поделитесь ссылкой на него со своими коллегами.


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


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


Наконец, подписывайтесь на канал, чтобы не пропустить дальнейшие разборы. Нам ещё много чего с вами нужно будет обсудить.


На этом пока что всё. С вами был боевой программер Дмитрий Карловский.

Подробнее..

Язык тестовых сценариев Testo Lang простая автоматизация сложных тестов

12.10.2020 08:12:38 | Автор: admin

Картинка для привлечения внимания


Если Вы разрабатываете более-менее сложный программный продукт, то Вам должна быть знакома ситуация, когда системные (end-to-end) тесты по тем или иным причинам автоматизировать не удаётся. На это могут быть разные причины, я приведу несколько примеров:


  • У приложения нет и не может быть API, за которое можно зацепиться, по соображениям безопасности;
  • Приходится поддерживать legacy-проект, про автоматизацию тестирования которого никто никогда не задумывался;
  • Во время тестирования задействуется сторонний продукт, например антивирус;
  • Необходимо проверить работоспособность продукта на большом количестве различных целевых платформ;
  • Тестовый стенд представляет собой сложную гетерогенную систему, включающую в себя промежуточное сетевое оборудование.

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


Если Вы ищете решение этой проблемы то прошу под кат.


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


кликнуть_мышкой_на_кнопке_с_надписью "Сохранить"напечатать_на_клавиатуре "Hello world"дождаться_надписи_на_экране "Готово"

При этом неважно, что именно вы тестируете: XAML-приложение, Qt-приложение, Electron-приложение, веб-страницу или вообще консольное приложение. Вы кликаете по экрану виртуальной машины и набираете текст на клавиатуре, а как приложение устроено внутри это Вас уже совершенно не волнует. Удобно? Конечно!


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


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


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


mouse click "Сохранить"type "Hello world"wait "Готово"

Это всё, что Вам надо написать на языке Testo Lang чтобы:


  1. Кликнуть на надпись на экране "Сохранить";
  2. Напечатать на клавиатуре "Hello world";
  3. Дождаться появления на экране строки "Готово".

Однако, мне бы не хотелось, чтобы у Вас сложилось ложное впечатление, будто бы Testo это какой-то аналог AutoIt или Sikuli. Нет, это не просто инструмент для автоматизации чего-то-там, это инструмент, заточенный именно под автоматизацию системных тестов. Testo берёт под свой контроль значительное количество задач, которые обычно выполняет тестировщик: определение того, какие тесты пора прогнать заново, подготовку виртуального стенда, составление отчёта о том, какие тесты свалились и в какой момент, и так далее.


Так, с этого момента поподробнее



Итак, мы в Вами говорим именно о системных (end-to-end) тестах. Системные тесты предполагают, что Вы тестируете программу не саму по себе в вакууме, а помещаете её в конкретное окружение и смотрите, как она с этим окружением справится. Под окружением может пониматься что угодно: и версия ОС, и наличие/отсутствие каких-то приложений/драйверов, и взаимодействие по сети, и соединение с Интернетом, и недостаток дискового пространства/оперативной памяти Да и много чего ещё.


Самый удобный способ создать такое окружение для программы это установить тестируемую программу внутрь виртуальной машины. Но виртуальную машину необходимо для начала создать и установить на неё операционную систему. Давайте посмотрим, как с этой задачей справляется платформа Testo. Мы предусмотрели специальные конструкции в языке тестовых сценариев Testo Lang, предназначенные для создания элементов виртуальной инфраструктуры Ваших стендов. Например, следующий сниппет объявляет "пустую" виртуалку:


machine my_super_vm {    ram: 2Gb    cpus: 2    iso: "ubuntu_server.iso"    disk main: {        size: 5Gb    }}

Эта конструкция создаёт виртуальную машину с 2Гб оперативной памяти, 2 ядрами процессора и 5Гб дискового пространства. При запуске такой виртуалки, начнётся процесс установки операционной системы из образа ubuntu_server.iso.


Это может быть несколько непривычно, но мы рассматриваем процесс установки операционной системы как ещё один тест, наравне с теми тестами, в которых проверяется собственно работоспособность Вашей программы. Это утверждение обретёт бОльший смысл, если мы на секунду представим, что мы разрабатываем не программу, а операционную систему. Может быть это какая-то специализированная система, например Alt Linux, а может быть мы разрабатываем игрушечную операционную систему just for fun. В любом случае, тестировать её как-то надо, а платформа Testo подходит для этой цели как нельзя лучше, потому что для неё нет никакой разницы, что мы тестируем: операционную систему или программу.


Так а что же делать с пустой виртуалкой? В качестве примера давайте посмотрим, как мог бы выглядеть тест, написанный на языке Testo Lang и выполняющий установку операционной системы:


test my_super_test {    my_super_vm {        start        wait "Language"        press Enter        wait "Install Ubuntu Server"        press Enter        wait "Choose the language"        press Enter        # И так далее        ...    }}

Здесь мы видим новую конструкцию языка, которая объявляет тест my_super_test. В этом тесте учавствует всего одна виртуалка my_super_vm. Тест начинается с включения виртуальной машины. Затем мы дожидаемся, когда на экране появится надпись "Language" и нажимаем клавишу Enter. Собственно, весь тест будет заключаться в последовательности таких действий: ждём наступления события, затем печатаем что-то на клавиатуре.


Разумеется, далеко не всегда хочется заморачиваться с установкой ОС и её первичной настройкой. Поэтому мы предусмотрели возможность импорта диска от другой виртуальной машины:


machine my_super_vm {    ram: 2Gb    cpus: 2    disk main: {        source: "prepared_vm.qcow2"    }}

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


Подготовка виртуалки и установка ОС это конечно всё очень хорошо, но тесты, тесты-то на мою программу где? Хорошо, давайте представим, что мы хотим протестировать инсталлятор нашей супер-программы. Представим также, что мы уже вручную подготовили виртуальную машину с Windows 10 на борту. Для простоты примера предположим, что инсталлятор нашей супер-программы уже скопирован на рабочий стол этой виртуалки. Тогда автотест на установку программы будет выглядеть следующим образом:


machine my_win_vm {    ram: 2Gb    cpus: 2    disk main: {        source: "my_windows_10.qcow2"    }}test my_installer_test {    my_win_vm {        # Запустим виртуальную машину        start        # Дождёмся появления рабочего стола        wait "Корзина"        mouse dclick "my_super_installator"        wait "Добро пожаловать"        mouse click "Далее"        wait "Выберите путь установки"        mouse click "Продолжить"        wait "Успешно" timeout 10m        mouse click "Закрыть"    }}

Правда, просто? А мы только разогреваемся ...


Что за wait такой и как он работает?



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


  1. Воздействие на виртуальную машину (mouse move/click, type, press, start/stop, plug flash и много чего ещё);
  2. Анализ происходящего на экране (wait).

Так вот, это действие wait и является основным видом визуальных проверок в языке Testo Lang. Действие wait дожидается появления на экране определённых объектов и событий в течение заданного таймаута (по умолчанию одна минута). И если событие не наступило генерируется ошибка (прямо как человек, который ждёт надписи "Успешно" пока у него не кончится, наконец, терпение).


Если мы говорим про поиск текста на экране виртуалки (то бишь на скриншоте), то обычно для этих целей используют какую-нибудь OCR (Optical Character Recognition) систему (например, Tesseract). Однако, это не совсем верный подход. Дело в том, что OCR-системы строятся исходя из двух постулатов:


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

В случае автотестов мы имеем совершенно иную ситуацию:


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

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


Такой подход к визуальным проверкам позволил нам добиться приемлемых результатов по точности детекта теста и, в то же время, получить хорошую скорость работы нейросетей даже на CPU. Разумеется, мы не собираемся останавливаться на достигнутом и будем и дальше совершенствовать точность нейросетей, а также добавим возможность нечёткого поиска по картинкам/иконкам.


На одном wait далеко не уедешь


Писать длинные тесты с помощью wait + click довольно муторно, особенно, если нет автоматического рекодера тестов. Тесты на основе визуальных проверок это скорее крайний вариант, когда нет другой возможности протестировать приложение или настроить тестовое окружение. Обычно всё же существует возможность выполнить какие-то проверки путём запуска процессов на гостевой системе, например с помощью bash-скриптов.


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


test my_super_test  {        my_super_vm {            exec bash "echo Hello world from bash"            exec python """                print("Hello from python")            """        }}

Если какая-либо команда из bash-скрипта завершится с ошибкой, то и весь тест будет считаться проваленным. Аналогично, если python-скрипт вернёт не ноль, то выполнение теста сразу же завершится.


На самом деле довольно многие тестовые сценарии выглядят следующим образом. Сначала с помощью wait + click на гостевую систему устанавливаются дополнения, а затем проверки сводятся к запуску процессов на виртуалке. Но при этом ничто не мешает в любой момент вернуться к визуальным проверкам. Тут всё зависит от Вас как Вам удобнее, так и делайте.


Кроме того, гостевые дополнения позволяют легко копировать файлы между виртуалкой и хостом:


test copy_demo {        my_super_vm {            copyto "/file/on/host" "/file/on/guest"            copyfrom "/file/on/guest" "/file/on/host"        }}

Да зачем же придумывать целый язык?



Мне кажется, многие читатели сейчас думают: "Ребят, серьёзно? Целый язык? Зачем? Ну напишите Вы библиотеку для питона или для чего ещё. Все разумные люди так делают".


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


  1. Мы бы хотели, чтобы у нашего языка был минимальный порог вхождения для людей, не знакомых с программированием;
  2. Мы бы хотели избавиться от лишней мишуры, присущей языкам общего назначения, оставив только то, что требуется для автотестов;
  3. Некоторые фичи, которые мы запилили в Testo-lang, просто так не воткнешь в библиотеку для Python!

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


Допустим, у Вас есть такой тест:


test run_installator {        my_super_vm {            copyto "/path/on/host.msi" "C:\\Users\\Testo\\Desktop\\setup.msi"            mouse dclick "setup"            ...        }}

Допустим, Вы его запустили, и он прогнался успешно. Если Вы тут же запустите его ещё раз он отработает мгновенно! Так а действительно, зачем прогонять тест ещё раз, если:


  1. Сам тест не менялся;
  2. Сборка Вашего инсталлятора не менялась.

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


Ух, круто. А что ещё умеет Testo?



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


# Сеть для соединения двух виртуальных машинnetwork net1 {    mode: "internal"}# Сеть для доступа в Итнернетnetwork internet {    mode: "nat"}machine my_super_client {    ...    nic server_side: {        attached_to: "net1"    }    nic internet: {        attached_to: "internet"    }}machine my_super_server {    ...    nic client_side: {        attached_to: "net1"    }}

Хотите добавить в стенд флешку? Нет проблем, пара строк и у Вас есть виртуальная флешка (можно даже скопировать на неё что-нибудь с хоста)


flash my_super_flash {    fs: ntfs    size: 2048Mb    #Папка с хоста, которую надо скопировать    folder: "/path/on/host"}

Хотите написать реально много тестов? Нет проблем, организуйте их в иерархию! Давайте для большей конкретики рассмотрим такой набор тестов:


  1. Установка ОС;
  2. Установка гостевых дополнений;
  3. Копирование и установка тестируемой программы на виртуалку;
  4. Тестирование фичи 1;
  5. Тестирование фичи 2.

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



На языке Testo Lang это будет выглядеть не сильно сложнее, чем на рисунке:


test install_os {    ...}test install_guest_additions: install_os {    ...}test install_app: install_guest_additions {    ...}test test_feature_1: install_app {    ...}test test_feature_2: install_app {    ...}

При первом запуске тестов, конечно, все тесты прогонятся от начала до конца. Допустим, при этом запуске не произошло никаких ошибок. В этом случае Testo запомнит, что все эти тесты завершились успешно и, соответственно, закешированы:



Если прямо сейчас заново запустить тесты, то они вовсе не прогонятся, так как ничего с момента последнего запуска и не поменялось. Но как только Вы соберёте новый билд своей тестирумой программы, Testo это отловит и инвалидирует кеш 3, 4 и 5 тестов:



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


Простой, но вполне реальный пример


Возможностей Testo может хватить на десяток статей, поэтому нам бы не хотелось сейчас Вам рассказывать про все из них. Давайте лучше притормозим и рассмотрим базовый, но реальный пример с автоматизацией тестирования простенького самописного standalone-приложения MySuperApp.


Это приложение написано на С++ с использованием библиотеки ImGui, у него нет никаких хуков для автоматизации тестирования, но мы всё равно очень хотим на каждую сборку проверять, что оно успешно запускается на Windows 10 и высвечивает нам окошко с надписью "MySuperApp is working!".


Что ж, для начала нам понадобится виртуалка. Для этого примера мы создадим виртуалку на основе уже существующей, вручную подготовленной виртуалки, на которую мы заранее установили Windows 10:


machine my_vm {    cpus: 2    ram: 4Gb    disk main: {        source: "${QEMU_DISK_DIR}/win10.qcow2"    }}

А как нам скопировать на виртуалку сборку с нашей программой? Давайте для этого воспользуемся флешкой!


flash my_super_flash {    fs: "ntfs"    size: 16Mb    folder: "./dist"}

Сборку с нашей программой поместим в каталог "./dist", а Testo позаботится о том, чтобы она оказалась на флешке.


Ну а теперь пишем сам тест!


test launch_my_simple_app {    my_vm {        ...    }}

Так, стоп, а с чего начать? Да всё просто просто записывайте все действия, которые Вы бы проделывали вручную! Для начала виртуалку надо включить.


start

Окей, а дальше? Дальше надо дождаться появления рабочего стола, конечно же:


wait "Recycle Bin" timeout 10m


Вставляем флешку


plug flash my_super_flash


Кликаем по надписи "USB Drive (E:)"


mouse click "USB Drive (E:)"


Открываем файловый менеджер:


mouse click "Open folder to view files"


Дважды кликаем по нашему приложению:


mouse dclick "MySuperApp"


Как же нам понять, что наше приложение успешно запустилось? Ну, мы знаем, что наше приложение при запуске должно высвечивать надпись "hello world". Поэтому если такая надпись появилась на экране, то это с большой долей вероятности свидетельствует о том, что всё хорошо. Это и будет наша основная проверка в тесте:


wait "hello world"

В конце теста не забываем вытащить флешку, и в сумме у нас получается такой скрипт:


test launch_my_simple_app {    my_vm {        start        wait "Recycle Bin" timeout 10m        plug flash my_super_flash        mouse click "USB Drive (E:)"        mouse click "Open folder to view files"        mouse dclick "MySuperApp"        wait "hello world"        unplug flash my_super_flash    }}

Вот собственно и всё, наш первый тест готов. А как его запустить? Да тоже ничего сложного, главное указать путь QEMU_DISK_DIR:


sudo testo run my_script.testo --param QEMU_DISK_DIR /var/lib/libvirt/qemu/images

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



А теперь, напоследок, давайте попробуем симитировать ситуацию, когда в нашу программу закралась ошибка.


Например, по какому-то нелепому стечению обстоятельств, мы собрали MySuperApp с динамической линковкой стандартной библиотеки С++ вместо статической. Если программа собрана таким образом, то для её работы на гостевой системе должен быть установлен пакет Microsoft Visual C++ Redistributable. А мы разрабатываем standalone-приложение, которое не должно иметь никаких зависимостей. У разработчика на хостовой системе Microsoft Visual C++ Redistributable конечно же установлен, поэтому такую ошибку легко не заметить.


Итак, мы подкладываем в каталог ./dist новую сборку нашего приложения и запускаем тесты заново. Вот что мы увидим:



При этом в выводе интерпретатора Testo будет указано, какой тест свалился и в какой именно строчке тестового сценария это произошло:



Тест свалился, ошибка выловлена!


Итоги


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


Скачать Testo абсолютно бесплатно без регистрации и СМС, а также ознакомиться с документацией можно у нас на сайте https://testo-lang.ru


Посмотреть больше примеров можно на youtube-канале https://www.youtube.com/channel/UC4voSBtFRjRE4V1gzMZoZuA

Подробнее..

С чего начинаются тесты

21.10.2020 08:16:04 | Автор: admin

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


Но с чего же начинать писать тесты для новых проектов? Лично для меня, как для программиста, самый интуитивный ответ это Unit-тесты. Однако опрометчиво накидываться на сочинение Unit-тестов может не только оказаться бесполезым занятием, но даже нанести вред в будущей разработке проекта.


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


А можно всех посмотреть?


Я думаю, многие из вас видели ту или иную версию вот этой картинки:



Существуют различные вариации этой картинки может меняться количество слоёв, их название, состав, но суть остаётся неизменной: тестирование программ можно разбить на несколько уровней, и при этом чем выше мы поднимаемся по этим уровням тем дороже и сложнее становится разработка и проведение тестов и, соответственно, количество тестов на уровне N+1 будет неизменно меньше, чем на уровне N.


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


Unit-тесты


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



Несколько особенностей Unit-тестов:


  1. Подразумевают принцип "белого ящика" то есть эти тесты требуют наличия и понимания исходных кодов программы.
  2. Дёшево стоят, можно наклепать тысячи, если не десятки тысяч Unit-тестов.
  3. Быстро прогоняются.
  4. По своей природе автоматизированы.
  5. Позволяют очень точно локализовывать ошибки можно узнать конкретную функцию/класс, которая работает неправильно.
  6. Плохо подходят для комплексных проверок.

Интеграционные тесты


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



Несколько особенностей интеграционных тестов:


  1. Всё ещё требуют принцип "белого ящика" тестирование блоков подразумевает понимание интерфейса этих блоков, как минимум. Внутреннее устройство модулей в интеграционном тестировании уже не участвует.
  2. Несколько сложнее Unit-тестов, т.к. зачастую блоки требуют много подготовительной работы, прежде чем они смогут "нормально" функционировать.
  3. Чуть сложнее поддаются автоматизации всё зависит от конкретного блока и его устройства/назначения.

API-тесты


Также в пирамиде иногда выделяют API-тесты. Иногда их относят к интеграционным, иногда как отдельный уровень, но сути это не меняет. Если какая-то часть программы имеет чётко выраженный и оформленный API, то можно выстроить тесты, "дергая" этот API и сверяя фактический результат с ожидаемым. Часто такое можно встретить в распределённых приложениях.


Сейчас такой вид тестов получил просто огромное распространение за счёт очень большой популярности REST API. Это очень удобный способ связывать клиент-серверные приложения между собой, и поэтому, конечно, создаётся очень заманчивое желание абстрагироваться от клиента и протестировать только серверную составляющую, дергая за "ниточки" API.


Несколько свойств этого вида тестирования:


  1. Может рассматриваться как подвид интеграционного тестирования.
  2. Требует чётко оформленного и (желательно) документированного API.
  3. Чаще всего нормально поддаётся автоматизации. Если речь идёт о популярном виде API (например, REST) то к вашим услугам большое количество готовых открытых и коммерческих инструментов, которые позволяют автоматизировать такие тесты на раз-два. Если же API нетипичное, то может потребоваться разработать собственную утилиту по вызову этого API и проверке результатов. В любом случае, стоимость автоматизации выше, чем у Unit-тестов.
  4. Позволяет протестировать очень большие самостоятельные компоненты программы и иногда всю программу в целом.

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


Системные тесты


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



Системные тесты направлены на тестирование всей программы в целом, с учётом окружения, в которой программе предстоит работать. Это уточнение очень важное, ведь программа редко когда может работать "сама по себе". Программа работает на одном или нескольких (в случае распределенных приложений) конкретных компьютерах. И на разных компьютерах (в разных условиях) тестируемая программа может повести себя по-разному. Например, клиент-серверное приложение может работать замечательно в обычной сети, но при этом может оказаться неработоспособным при наличии между клиентом и сервером маршрутизатора с настроенным NAT.


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


Несколько заметок о системных тестах:


  1. Предполагают тестирование по методу "черного ящика". Никаких знаний об исходных кодах или особенностях работы программы только UI-тесты.
  2. Наиболее комплексный вид тестирования даже распределенные приложения тестируются вместе, а не по отдельности.
  3. Даёт наибольшую степень уверенности, что протестированные фичи действительно будут работать у конечных пользователей.
  4. Самый дорогой вид тестов.
  5. Долго или очень долго прогоняются.

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


Что ж, ликбез в теорию я заканчиваю, и перехожу к самой интересной части какой вид тестов лучше писать в первую очередь?


Не спешите с Unit-тестами


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


Представим ситуацию, что вы разрабатываете совершенно новое приложение. Неважно, как именно вы его разрабатываете на основе четко сформированных требований или же "по наитию". В какой-то момент времени вам (надеюсь) захочется написать тесты. Это может случиться как только у вас получится что-то работающее, или же тесты окажутся вообще вашим самым первым шагом если вы придерживаетесь концепции TDD (Test Driven Development). Неважно когда, но этот момент настанет. И о каких же тестах вы подумаете в первую очередь?


Лично я, как разработчик, всегда думаю в первую очередь о Unit-тестах! А почему бы и нет? Ведь для меня программа это, в первую очередь, исходный код. Если я сам пишу код и знаю, как он работает/должен работать, то для меня самый логичный первый шаг покрыть его тестами. Больше того, я уже знаю, как это делается, я делал это тысячу раз, это зона моего комфорта. Поэтому я с чувством выполнения великой миссии (тесты это же хорошо!) начинаю стандартную возню с Unit-тестами: скачиваю свой любимый фреймворк, продумываю абстрактные классы вместе с интерфейсами, занимаюсь mock-ированием объектов И радуюсь, какой я молодец.


И тут я загоняю себя в две потенциально-опасные ситуации. Посмотрим на первую проблему.


Кто может быть первым кандидатом на Unit-тесты? Лично я бы начал с обособленных участков кода и классов, которые выглядят вполне самостоятельными. Например, я решил, что для моего проекта мне понадобится самописная хеш-таблица. Это же отличный кандидат на покрытие Unit-тестами! Интерфейс понятен и меняться не будет, так что написать тесты сам бог велел. И вот я воодушевлённо трачу своё рабочее время, накидывая десятки тестов. Может быть, я даже выловлю несколько багов в своём коде (боже, как я хорош, как мощны мои тесты) и и спустя два месяца я вдруг понимаю, что хеш-таблица мне вовсе не нужна, лучше бы её заменить базой данных. И все мои рабочие часы на Unit-тесты (и выловленные баги) летят в помойку.


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


Отловив все очевидные участки кода, которые можно покрыть Unit-тестами, вы вдруг понимаете, что вы покрыли всего 10% кода (ну нет у вас сейчас четких обособленных модулей с внятными интерфейсами на такой стадии проекта). Тогда вы начинаете беспощадно рефакторить свой код выделяете абстрактные классы, выстраиваете зону ответственности между модулями, инкапсулируете, инкапсулируете, инкапсулируете занимаетесь, в общем-то, полезными делами, честно говоря. Ну и каждый свой успех отмечаете очередной порцией Unit-тестов, ведь ради них, родимых, всё и затевается!


Спустя пару недель работы вы получаете 60% покрытого тестами кода. У вас появилась сложная иерархия классов, mock-объекты, а общее количество Unit-тестов перевалило за 100500. Всё хорошо, так ведь?


Всё хорошо ровно до тех пор, пока вы не увидите, что вашу архитектуру можно было бы улучшить (а с развитием проектов, особенно новых, такие наблюдения случаются регулярно). И вместо того, чтобы смело ринуться в дебри рефакторинга, вы начинаете грустно думать о 100500 Unit-тестах и о том, как теперь вам придётся их переделывать. Вы становитесь заложником своих собственных Unit-тестов, которые начинают сковывать вашу гибкость в принятии архитектурных решений. И выбор у вас получается так себе. Либо решиться на рефакторинг и спустить в унитаз всё время, потраченное на Unit-тесты, либо (ещё хуже) оставить всё как есть, и затем бороться с не самой удачной (как вы теперь понимаете) архитектурой.


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


Вовсе нет. Просто для любого нового проекта это абсолютно нормальная ситуация в какой-то момент понять, что изначальная архитектура или затея вышла не слишком удачной и что нужно всё (или практически всё) переделывать. Вам всё равно с высокой долей вероятности придётся через это пройти. И наличие Unit-тестов вас не только не спасёт оно даже сыграет с вами злую шутку: вместо того, чтобы взвешенно и объективно принять решение о реструктуризации кода, вы будете только грустно вспоминать о потраченных часах и даже днях на составление сотен, тысяч юнит-тестов. Мне было бы жалко времени, потраченного на Unit-тесты, которые теперь надо выкинуть в помойку и начинать писать заново.


Лучше спешите с системными тестами!


Так что же я предлагаю? А предлагаю я взглянуть на старый рисунок по-новому:



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


Как я уже упоминал выше, системные тесты это тесты программы в целом. При этом программа представляется именно в том виде, в котором её увидит пользователь. Фактически, системные тесты заставляют нас взглянуть на программу как на законченный продукт (хоть он таким сейчас и не является). Если другими словами, то вместо подхода от "от частного к общему" мы начинаем писать тесты "от общего к частному". А это позволяет нам убить сразу двух зайцев:


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

Звучит неплохо, не правда ли? Причём, системные тесты обладают ещё рядом очень приятных бонусов:


  1. В условиях зафиксированных требований к программе системные тесты не могут сильно меняться. Это возможно благодаря самой природе системных тестов: они рассматривают программу как "черный ящик" без какой-либо привязки к архитектуре и особенностям реализации. А это значит, что вы можете свободно менять архитектуру своей программы по первому требованию, и никакие тесты менять не придется! Ну, разве что, немного подкорректировать если у вас добавилась новая зависимость или что-то в таком духе.
  2. Системные тесты могут писать аналитики или даже тестировщики не отнимая, таким образом, ценное время программистов. Также это позволяет аналитикам дать программистам более четкое понимание, что именно они хотят увидеть в программе. Программистам лишь останется добиться того, чтобы тесты проходили, не ломая голову над вопросом "чего же от меня хотят".
  3. Системные тесты это очень комплексный вид тестов. Если ваша программа проходит сложный комплексный тест то вы уже с довольно высокой долей вероятности можете быть уверены, что "в целом, наверное, все компоненты нормально работают". В Unit-тестах наоборот уверенность в одном классе не дает абсолютно никакой уверенности в работоспособности программы в целом.

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


Впрочем, сейчас на этом фронте не всё так уж и плохо существует очень много коммерческих решений, которые позволяют в том или ином виде решить эту проблему: Testcomplete, Squish, Ranorex, Eggplant это лишь самые известные примеры таких систем. У всех у них есть свои достоинства и недостатки, но в целом со своей задачей по автоматизации системных тестов они справляются (хоть и стоят очень немалых денег).


А вот среди бесплатных решений выбора особо нет. По крайней мере не было.


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


А что же другие тесты?


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


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


В развитием проекта вы обнаружите, что вас уже не устраивает только проверка "в целом всё работает". Со временем архитектура вашего нового приложения неизбежно "устаканится", крупных изменений будет всё меньше и меньше. В какой-то момент вы поймёте, что вы хотите навесить побольше проверок на какой-нибудь компонент (потому что он уже точно никуда из проекта не денется, и его интерфейс точно проработан), или даже на конкретный класс А для особо важных участков кода и вовсе желательно проработать все возможные ветвления. Или же вам очень интересно, как поведёт себя программа при непосредственном обращении к API. Чувствуете, да? Вот и другие тесты снова в деле!


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


  1. В самом начале, когда проект активного развивается с нулевого состояния, необходимо писать только системные тесты. Тестов нужно написать столько, чтобы у вас была четкая уверенность, что "в целом моя программа работает нормально". Это будет отличный базис для дальнейшей работы.
  2. По мере развития проекта и "устаканивания" его архитектуры и основных компонентов можно добавлять интеграционные тесты. Если у проекта появилось чётко выраженное и стабильное API нужно начинать писать API-тесты.
  3. Наконец, для особо важных изолированных участков кода, от правильной работы которых зависит очень многое, можно написать Unit-тесты.
  4. Помнить, что из любого правила есть исключения. При возникновении достаточно веских объективных причин повышайте приоритет интеграционных и Unit-тестов. Не нужно откладывать разработку тестов более низкого уровня, если вы в них действительно нуждаетесь здесь и сейчас.

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


Итоги


Вроде бы про разработку "от общего к частному" уже давным давно всё известно вдоль и поперёк, но при этом тесты всё равно упорно составляются "от частного к общему". Возможно, это дело привычки, а возможно в том, что так просто удобнее ведь написать двадцать Unit-тестов всегда проще, чем пару системных тестов.


Но по мере развития инструментов для автоматизации системных тестов перспектива сходу автоматизировать end-to-end тесты уже не кажется такой уж пугающей. Возможно, вам понравится разрабатывать тесты "сверху вниз" ведь так их можно разрабатывать ровно по мере их надобности.

Подробнее..

Перевод Что необходимо учитывать при юнит-тестировании фронтенда

13.11.2020 10:21:32 | Автор: admin
Привет, Хабр!

Обращаем ваше внимание еще на одну новинку, доступную у нас в предзаказе книгу о юнит-тестировании.



Автор сегодняшней публикации кратко и доступно рассказывает о достоинствах unit testing и TDD на примере фронтенда.

Приятного чтения!


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

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


Пишем тестируемый код



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

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



Пример: исследование переиспользуемых компонентов React, предоставляемых для совместного использования на Bit.dev

Порочный круг: что будет, если не писать юнит-тесты



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

Как мы тестируем наш код при разработке



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

Лишь после этого, когда весь функционал уже работает, акцент смещается на юнит-тестирование.

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

Из-за такой постановки вопроса как владельцы продукта, так и разработчики относят юнит-тестирование к разряду издержек.

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

Поэтому я полагаю, что практика TDD является важнейшим этапом, предваряющим практику юнит-тестов.

Как часто прогоняются юнит-тесты



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

Насколько часто измерять покрытие кода тестами



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

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

Выход из этого круга



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

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


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

Итак, какова же истинная ценность всей этой работы



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

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


Итак, давайте заполнять пробелы и возвращаться в колею, усваивая практики DevOps и приучаясь писать хорошие юнит-тесты, также придерживаясь разработки через тестирование.
Подробнее..

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

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? Если у вас частая регрессия и низкое покрытие вы догадываетесь, что стоит изменить?

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

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

Подробнее..

CDD Cli Driven Development

03.04.2021 10:09:09 | Автор: admin

Все-таки самоизоляция не проходит бесследно. Сидишь себе дома, а в голову разные мысли приходят. Как, чем осчастливить человечество? И вот оно: CDD! (И еще PDD / SOLID / KISS / YAGNI / TDD / Bootstraping...)

1. CDD - Cli Driven Development - Новый подход

Немного истории

Как-то поручили мне сделать Cli в одном нашем embedded устройстве. Разумеется, C/C++ (пусть будет C++, раз ресурсов хватает). Конечно есть много Cli-фреймворков.

Но я сделал свой вариант.

Для Linux можно использовать <termios.h> и получать коды символов после установки свойств терминала:

signal(SIGINT, SIGINT_Handler); // Ctrl+Csignal(SIGTSTP, SIGTSTP_Handler); // Ctrl+Zint res_tcgetattr = tcgetattr(STDIN_FILENO, &terminal_state_prev);terminal_state_new = terminal_state_prev;terminal_state_new.c_lflag &= ~(ICANON | ECHO);int res_tcsetattr = tcsetattr(STDIN_FILENO, TCSANOW, &terminal_state_new);

Для Windows можно использовать <conio.h>.

Добавляем немного классов, делаем список команд, и добавляем команды по типу:

{ Cli_Command_Abstract_t *cmd = new Cli_Command_Abstract_t(Cli_Command_ID_help); cmd->Add(help_keyword); cmd->Help_Set("show this help, \"help full\" - show all available commands"); command_tree->Add(cmd);}

И все-бы ничего, пока команд 10-20. Ну пусть еще help / quit / debug cli (типа очень нужная команда - об этом позже). Интересно, что основной функционал уложился в 20 команд, а вот разные обвязки Управление SNMP / Syslog / NTP / Users / FTP / SSH / VLAN и у нас - 250 команд. Ух ты! Начинаются проблемы с монолитным приложением, и очень хочется разбить все на модули, желательно попроще и поменьше. И вот отсюда и начинается CDD - Cli Driven Development.

1.1 Использование Cli в различных типах приложений

Вообще, Cli, не смотря на GUI, используется во многих типах приложений: САПР, игры, базы данных, среды выполнения (Erlang, Lua и др.), IDE. Можно утверждать, что включение консоли могло бы сделать многие приложения более удобными (например, можно представить Paint с командной строкой: количество команд невелико, VBA будет лишним, но одна лишь возможность выполнения скриптов могла бы значительно изменить работу с программой).

1.2 Введение в CDD

Cli-интерфейс жив и развивается. Cisco-like - это вполне вполне рабочий термин.

Что же может современный Cli? - Довольно много:

  • развитую систему команд с выводом подробной информации, в том числе об аргументах команды;

  • группировку команд ("уровни");

  • задание группы объектов для управления ("параметры");

  • логгирование;

  • исполнение скриптов;

  • типизированный ввод данных с валидацией;

Я придумал еще одну функцию: debug cli - проверка команд (CMD_ID / CMD_Item / CMD_Handler)

  • может показать число ID ("задуманные команды"), Realized- и NotRealized-команды для каждого модуля; (В идеале счетчики ID, Realized должны быть равны, но если NotRealized не равен 0, то это еще один стимул для разработчика: ну осталось всего-то 30...20...5...2 нереализованных команд - неужели оставим так? может лучше доделать? - и это работает!)

1.3 Основные идеи CDD

Можно сформулировать основные идеи CDD:

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

  2. Модульное построение: любой модуль можно убрать или заменить с предсказуемыми изменениями в функционале.

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

1.4 mCli - Реализация CDD

CDD использовано при построении mCli - Cli-фреймворка модульного типа (github.com/MikeGM2017/mCli). В текущем состоянии имеются события, типы и модули.

1.4.1 События mCli

В простейшем виде для ввода с клавиатуры нужно определение кода нажатой клавиши и (отдельно) определение нажатия Enter (ввод команды) и Ctrl+C (прерывание команды). В полном наборе необходимо определение нажатия Enter (ввод команды), Ctrl+C (прерывание команды), Up/Down (просмотр истории команд), Left/Right/Home/End (перемещение по строке ввода), Back/Delete (изменение строки ввода).

1.4.2 Типы mCli

mCli предполагает использование типов при вводе данных. В текущей реализации имеются следующие типы:

  • Word / Word_List / Word_Range (ключевые слова, List - можно ввести несколько ключевых слов через запятую, Range - выбор одного ключевого слова из нескольких вариантов)

  • Int / Int_List / Int_Range

  • Str

  • IP4 / IP6

  • MAC

  • Date / Time / DateTime

  • EQU_Range ( == != > < >= <= - для использования в скриптах, условное выполнение)

  • Rem (комментарий - для использования в скриптах)

1.4.3 Модули mCli

Модули mCli можно разделить на базовые, платформо-зависимые и кастомные.

Базовые модули:

  • Base_Quit (выход из приложения)

  • Base_Help (вывод информации по командам и их аргументам)

  • Base_Modules (вывод информации по задействованным модулям)

  • Base_History (история команд)

  • Base_Script (выполнение скриптов)

  • Base_Rem (комментарий, для использования в скриптах)

  • Base_Wait (пауза, для использования в скриптах)

  • Base_Log (управление логом)

  • Base_Debug (проверка списка команд, определение нереализованных команд)

  • Check (условное выполнение, для использования в скриптах)

Платформо-зависимые модули

Вывод:

  • Output_printf (Linux/Window)

  • Output_cout (Linux/Window)

  • Output_ncurses (Linux)

  • Output_pdcurses (Linux/Window)

Ввод:

  • Input_termios (Linux)

  • Input_conio (Window)

  • Input_ncurses (Linux)

  • Input_pdcurses (Linux/Window)

Кастомные модули:

  • ConfigureTerminal (демо: тестирование переменных)

  • SecureTerminal (демо: вход в модуль по паролю)

  • TestTerminal (демо: тестирование типов)

1.5 Объединение модулей в mCli

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

Cli_Modules Modules;// Modules Add - BeginModules.Add(new Cli_Module_Base_Rem(Str_Rem_DEF, Cli_Output));bool Cmd_Quit = false;Modules.Add(new Cli_Module_Base_Quit(Cmd_Quit));Str_Filter str_filter('?', '*');Modules.Add(new Cli_Module_Base_Help(User_Privilege, Modules, str_filter, Cli_Output));Modules.Add(new Cli_Module_Base_Modules(User_Privilege, Modules, str_filter, Cli_Output));Cli_History History;Modules.Add(new Cli_Module_Base_History(History, Cli_Output));Modules.Add(new Cli_Module_Base_Log(Cli_Input));bool Cmd_Script_Stop = false;int Script_Buf_Size = 1024;Modules.Add(new Cli_Module_Base_Script(History, Cli_Output,            Str_Rem_DEF, Cmd_Script_Stop, Cmd_Quit, Script_Buf_Size,            CMD_Processor));bool Log_Wait_Enable = true;bool Cmd_Wait_Stop = false;Modules.Add(new Cli_Module_Base_Wait(Log_Wait_Enable, Cmd_Wait_Stop, Cli_Input, Cli_Output));Modules.Add(new Cli_Module_Test_Tab_Min_Max());Modules.Add(new Cli_Module_Test_Terminal(Cli_Input, Cli_Output));Modules.Add(new Cli_Module_Base_Debug(User_Privilege, Modules, Levels, CMD_Processor, Cli_Output));Modules.Add(new Cli_Module_Check(Modules, Values_Map, str_filter, Cli_Output, Cmd_Script_Stop));// Modules Add - End

1.6 CDD и SOLID

SOLID в CDD достаточно легко обнаружить на уровне подключения и объединения модулей. Какие-то модули практически всегда используются, например Cli_Output нужен в большинстве модулей. Другие - гораздо реже (например, Cli_Input нужен только в модулях, в которых команда требует подтверждения).

Таким образом, SOLID в CDD - это:

  • S - каждый модуль отвечает за свой круг задач

  • O - здесь есть проблема: в каждом модуле есть enum Local_CmdID, и получается, что при наследовании список Local_CmdID не так просто расширить? Но в новом модуле мы можем завести новый enum Local_CmdID или (лучше) можно ввести новый enum Local_CmdID только для новых команд, стартующий с последнего элемента предыдущего enum (для этого можно использовать CMD_ID_LAST)

  • L - модуль может быть заменен на другой, с доработанной реализацией

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

  • D - модули связываются на верхнем уровне

1.7 CDD и KISS

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

На уровне модуля команды могут различаться флагами или дополнительными параметрами. Но реализация у них может быть одна. Например, "help" и "help full" реализуются одним методом, в качестве параметра принимающий строку фильтра - "*". Так что KISS сохраняется в таком смысле:

  • команда выполняется методом, имеющим несколько флагов (да, из-за этого метод делается чуть сложнее, зато несколько команд Cli могут выполняться однотипно).

1.8 CDD и DRY

Повторяющийся код выносим в отдельный класс, создаем объект и ссылку на него используем при создании модуля.

1.9 CDD и YAGNI

Нужно убрать какой-то ненужный функционал? - Убираем ненужный модуль (или команды в модуле). За счет слабой связности модулей это несложно.

1.10 CDD и Bootstraping

В некоторых случаях (например, Embedded Baremetal) у нас есть только консоль. CDD может быть применено для разработки приложения "с нуля".

1.11 CDD и TDD

За счет наличия скриптов и модуля условного исполнения автоматизация тестирования сводится к следующему сценарию:

  • вручную вводится последовательность тестируемых команд;

  • история команд сохраняется в файле скрипта;

  • при необходимости скрипт редактируется / дополняется проверкой правильности выполнения;

  • вызов скрипта добавляется в общий скрипт тестирования или используется сам по себе;

1.12 CDD и GUI

А что GUI? GUI (да и Web - тоже) пусть посылает текстовые команды в Cli - эстетично, наглядно, надежно.

2. CDD и PDD

А вот еще и PDD!!!

2.1 PDD - Provocation Driven Development - еще один новый термин :)

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

  • C провоцирует на нарушения доступа к памяти и на плохо контролируемые приведения типов;

  • C++ - на создание монолита (если за этим не следить, то имеем типовой пример: classMyCoolGame;myCoolGame.Run());

  • SQL, Lua - "все есть таблица";

  • Assembler - "стандартов нет";

  • Java - "щас понаделаем объектов";

  • JavaScript - "щас наподключаем библиотек, не самим же все делать"; и так далее - дополнительные примеры каждый, думаю, сможет придумать.

2.2 Что есть PDD для CDD?

В первую очередь - это тенденция на разбиение проекта на модули. Действительно:

  • Есть объект управления? - Выносим в модуль.

  • Есть повторяющийся код? - Выносим в модуль.

  • Новый функционал? - Добавляем новый модуль.

  • Новая архитектура? - Заменяем модули.

Описание команд - это текстовое описание функционала, фактически мы получаем DSL. Чтобы получить информацию о доступном функционале, достаточно ввести команду "help".

Предсказательный характер архитектуры:

  • пусть в расчетах на каждую Cli-команду отводим 1 (один) человеко-день. Да, можно за 1 день ввести 10-20 простых Cli-команд (да, простые или однотипные команды реализуются быстро), но не нужно обманываться: будет (обязательно будет!) функция, которая потребует 10 дней на реализацию и тестирование. Поэтому проект средней сложности на 200-300 Cli-команд займет 200-300 человеко-дней (хотя, это скорее оценка "сверху", реально проект может быть закончен раньше).

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

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

В Cli достаточно легко задавать обработку группы объектов. Можно, например:

  • ввести список объектов в команду;

  • ввести фильтр по именам объектов в команду;

  • ввести список объектов как параметр;

  • или даже ввести модуль Selection для управления группировкой объектов. Таким образом, можно использовать различные варианты групповой обработки.

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

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

3. Встроенный язык скриптов

3.1 Модуль Check

Условное выполнение реализовано в модуле "Check".

Для условного выполнения команд, в принципе, достаточно всего двух команд: "check label " - установка метки "check if == goto " - условный переход (здесь сравнение может быть не только на равенство: == != > < >= <= - вот полный список, но при этом команду можно оставить одну и ту же, а операторы сравнения ввести в виде списка возможных значений)

Переменные в простейшем случае - глобальные, заносятся в map<string,string>, для чего в модуле предусмотрен виртуальный метод .To_Map().

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

3.2 Модуль Check vs Lua

Да, вместо встроенных модулей скриптов и условного выполнения можно подключить Lua. Однако, вместо нескольких команд (в действительности модуль условного выполнения Check получается не такой уж маленький - более 30 команд, хотя и однотипных) подключение Lua означает большое увеличение размера исполняемого файла, а в некоторых случаях это может быть критичным. Но как вариант, Lua выглядит очень привлекательно.

3.3 Модуль Check vs Erlang

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

4. CDD vs Erlang

Неплохая попытка, подход Erlang - довольно похож на CDD. Но задумаемся, в чем PDD для Erlang? - "Ошибаемся и еще раз ошибаемся, а система все равно работает". Это, конечно, сильно. Поэтому вопрос: "CDD или Erlang" безусловно стоит. Но CDD можно реализовать на многих языках программирования (C/C++, C#, Java, JavaScript). А у Erlang - очень специфичный подход. Может быть, не Erlang vs CDD, а Erlang + CDD ??? Кажется, надо попробовать...

5. CDD и дробление монолита

Примерный путь преобразования монолита в CDD-приложение:

  • создаем CDD-приложение из Base-модулей;

  • legacy-монолит добавляем в виде нового Cli-модуля на новом "уровне" с минимальными командами вида "version get" / "info get" - на первом этапе достаточно "установить контакт" с монолитом;

  • в новом модуле вводим команды, специфичные для него: "start" / "stop" / "configure" ;

  • скорее всего новые команды будут группироваться вокруг каких-то понятий / объектов / процедур и т.п. - это повод выделить такие группы в отдельные модули + объекты управления; при этом в основном монолите вводятся ссылки на выделенные объекты;

  • в результате должен получиться набор модулей, причем каждый модуль должен содержать не более 10-20 команд;

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

6. Итоги

CDD выполняет SOLID, KISS, DRY, YAGNI, Bootstraping, TDD.

CDD провоцирует на модульное построение.

CDD дает возможность выполнения скриптов и внутреннее тестирование.

CDD может быть основой большого количества типов приложений.

CDD позволяет вводить новый функционал прогнозируемым способом.

CDD может быть основой построения OS.

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

CDD дает возможность разделения работ:

  • постановщик задачи описывает новый модуль в виде набора команд;

  • исполнитель реализует команды;

  • тестировщик пишет скрипты для проверки нового функционала.

CDD поддерживает введение нового функционала, в том числе на разных уровнях:

  • новые модули;

  • новые команды в существующих модулях.

CDD обеспечивает безопасность при вводе команд:

  • команды парсятся, данные валидируются, сделать что-то вне Cli-команд невозможно (если, конечно, не вводить команды типа exec / system / eval).

CDD фактически дает документацию по функционалу приложения:

  • достаточно подать команду "help * verbose" - и описание команд и их аргументов уже есть.

Этого мало?

Тогда вот вам напоследок: CDD позволяет захватить мир. КМК

Да, и Linux стоит переписать по CDD. КМК

Подробнее..

Почему большинство юнит тестов пустая трата времени? (перевод статьи)

17.05.2021 16:18:04 | Автор: admin

Автор: James O Coplien

Перевод: Епишев Александр

1.1 Наши дни

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

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

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

Классы превратились в объекты анализа, и, в определенной степени, проектирования. Популярной техникой дизайна стали CRC-карты (обычно представляющие Классы, Ответственности и Кооперацию), каждый класс в которых представлен отдельным человеком. Объектная ориентация начала ассоциироваться с антропоморфным дизайном. Классы, к тому же, превратились в единицы администрирования, дизайна и программирования, а их антропоморфная суть усилила стремление каждого создателя класса его протестировать. Поскольку у некоторых методов класса сохранилась такая же контекстуализация, как и у функции FORTRAN, у программистов возникла необходимость предоставлять контекст перед выполнением метода (помните, что мы не тестируем классы и, даже, не тестируем тестовые объекты, единицей функционального теста является метод). Юнит тесты обеспечивали выполнение сценариев драйверами. Моки - контекст состояния окружения (энва) и других методов, от которых зависел тестируемый метод. При подготовке к тесту, тестовые окружения поставляли необходимые средства для создания каждого объекта в его правильном состоянии.

1.2 Лекарство хуже болезни

Конечно же, юнит-тестирование не является проблемой исключительно объектно-ориентированного программирования, de rigueur (лат. "крайней необходимостью"), скорее всего, его сделала комбинация объектной-ориентированности, эджайла, разработки программного обеспечения, а также рост инструментов и вычислительных мощностей. Как консультант, я часто слышу вопросы о юнит-тестировании, включая следующий от одного из своих клиентов, Ричарда Якобса (Richard Jacobs) из Sogeti (Sogeti Nederland B.V.):

Второй вопрос касается юнит-тестов. Если я правильно припоминаю, вы говорили, что юнит-тесты - это пустая трата времени. Во-первых, я был удивлен. Тем не менее, сегодня моя команда сообщила, что их тесты сложнее, чем сам код. (Это не та команда, которая изначально написала код и юнит тесты. Поэтому некоторые тесты застают их врасплох. Текущая команда более высоко квалифицирована и дисциплинирована.) В таком случае, по моему, это пустая трата времени... Когда я ежедневно программировал, то создавал действительно тестируемый код, однако, почти никогда не писал никакие юнит тесты. При этом я заслуживал признание за свой качественный код и почти безошибочное программное обеспечение. Мне хотелось бы разобраться, ПОЧЕМУ такой вариант работал в моем случае?

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

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

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

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

1.3 Тесты ради тестов и спроектированные тесты

У меня был клиент из Северной Европы, разработчики которого должны были предоставить 40% покрытия кода, для, так называемого, 1-го уровня зрелости программного обеспечения, 60% для 2-го уровня и 80% для 3-го, хотя были и стремящиеся к 100%. Без проблем! Как вы могли бы предположить, достаточно сложная процедура с ветвлениями и циклами стала бы вызовом, однако, это всего лишь вопрос принципа divide et impera (разделяй и властвуй). Большие функции, для которых 80% покрытие было невозможным, разбивались на множество более мелких, для которых 80% уже было тривиальным. Такой подход повысил общий корпоративный показатель зрелости команд всего лишь за один год, потому как вы обязательно получаете то, что поощряете. Конечно же, это также означало, что функции больше не инкапсулировали алгоритмы. Невозможным оказалось понимание контекста выполняемой строки, точнее тех, которые предшествуют и следуют за ней во время выполнения, поскольку эти строки кода больше не имеют прямого отношения к той, которая нас интересует. Такой переход в последовательности теперь происходил благодаря вызову полиморфной функции - гипер-галактической GOTO. Даже если всё, что вас беспокоит, - это покрытие решений (branch coverage), это больше не имеет значения.

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

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

Задумайтесь на секунду о вычислительной сложности этой задачи. Под 100% покрытием, я подразумеваю проверку всех возможных комбинаций всех возможных ветвлений, проходящих через все методы класса, которые воспроизводят все возможные конфигурации битов данных, доступные этим методам, в каждой инструкции машинного языка во время выполнения программы. Все остальное - это эвристика, о корректности которой нельзя сделать никаких формальных заявлений. Число возможных путей выполнения с помощью функции невелико: скажем, 10. Перекрестное произведение этих путей с возможными конфигурациями состояний всех глобальных данных (включая данные экземпляра, которые для области видимости метода являются глобальными) и формальных параметров в действительности же очень велико. Перекрестное произведение этого числа с возможной последовательностью методов внутри класса представляется счетно-бесконечным. Если вы возьмете несколько типичных чисел, то быстро осознаете, насколько вам повезло, если получите покрытие лучше, чем 1 из 1012.

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

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

Помните, однако, что автоматизированный хлам - это всё ещё хлам. И те из вас, у кого есть корпоративная Lean-программа, могли заметить, что основы производственной системы Toyota, которые лежали в основе Scrum, очень сильно противились автоматизации интеллектуальных задач (http://personeltest.ru/away/www.computer.org/portal/web/buildyourcareer/Agile Careers/-/blogs/autonomation). Более эффективно - это постоянно удерживать человека процессе, что становится еще более очевидным при исследовательском тестировании. Если вы собираетесь что-то автоматизировать, автоматизируйте что-нибудь ценное. Автоматизировать необходимо рутинные вещи. Возможно даже, вы получите еще больше прибыли от инвестиций, если автоматизируете интеграционные тесты, тесты для проверки регрессионных багов, а также системные, вместо того, чтобы заниматься автоматизацией юнит тестов.

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

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

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

1.4 Убеждение, что тесты умнее кода, говорит о скрытом страхе или плохом процессе

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

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

Если у вас большой объем юнит-тестов, оцените обратную связь в процессе разработки. Интегрируйте код чаще; сократите время сборки и интеграции; сократите количество юнит тестирования и перейдите больше к интеграционному.

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

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

Тем не менее, будем честны, ошибки будут всегда. Тестирование никуда не денется.

1.5 У тестов с низким уровнем риска низкая (даже потенциально отрицательная) отдача

Как то я озвучил своему клиенту предположение о том, что множество их тестов могут быть тавтологическими. Предположим, задача какой-то функции - это присвоение X значения 5, и я готов поспорить, что существует соответствующий тест для данной функции, который, после запуска, проверяет, равняется ли X 5. Снова же, хорошее тестирование, основывается на тщательном размышлении, а также базовых принципах управления рисками. Управление рисками строится на статистике и теории информации; если тестировщики (или, по крайней мере, менеджер по тестированию) не обладают хотя бы элементарными навыками в этой области, вы, с большой вероятностью, создаете множество бесполезных тестов.

Разберем тривиальный пример. Цель тестирования - предоставить информацию о вашей программе. (Тестирование само по себе не повышает качество; это делают программирование и проектирование. Тестирование лишь сообщает об упущениях команды в создании правильного проектирования и соответствующей реализации.) Большинство программистов хотят услышать информацию о том, что их программный компонент работает. Поэтому, как только в проекте трехлетней давности была создана первая функция, тут же для нее был написан и юнит тест. Тест ни разу не падал. Вопрос: Много ли информации содержится в этом тесте? Другими словами, если 1 - это успешно выполненный тест, а 0 - упавший, тогда сколько будет информации в следующей строке результатов:

11111111111111111111111111111111

Существует несколько возможных ответов, обусловленных видом применяемого формализма, хотя большинство из них не верны. Наивный ответ - 32, однако, это биты данных, а не информации. Возможно, вы информационный теоретик и скажете, что количество битов информации в однородной двоичной строке равносильно двоичному логарифму длины этой строки, которая в данном случае равна 5. Однако это не то, что я хочу знать: в конце концов хотелось бы понять, сколько информации можно получить после одноразового прогона такого теста. Информация основывается на вероятности. Если вероятность успешного прохождения теста равняется 100%, тогда, по определению теории информации, этой информации нет вообще. Ни в одной из единиц указанной выше строки не содержится почти никакой информации. (Если бы строка была бесконечно длинной, то в каждом тестовом прогоне было бы ровно ноль битов информации.)

Далее, сколько бит информации в следующей строке тестовых прогонов?

1011011000110101101000110101101

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

00000000000000000000000000000000

в которой, фактически, нет никакой информации, в том числе, даже о процессе улучшения качества.)

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

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

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

Если у вас есть подобные тесты - это второй претендент на удаление.

Третий набор для удаления - тавтологические тесты. Я сталкиваюсь с ними чаще, чем вы можете себе представить, особенно среди последователей, так называемой, разработки через тестирование (TDD). (Кстати, проверка this на ненулевое/не пустое (non-null) значение при входе в метод, не является тавтологической, и может быть очень информативной. Однако, как и в случае с большинством юнит тестов, лучше сделать ассершн, чем пичкать свой тестовый фреймворк подобными проверками. Подробнее об этом ниже.)

Во многих компаниях, единственные тесты с бизнес-ценностью - это те, в основании которых лежат бизнес-требования. Большинство же юнит тестов основываются на фантазиях программистов о том, как должна работать функция: на их надеждах, стереотипах, а иногда и желаниях, как все должно было бы быть. У всего этого нет подтвержденной ценности. В 1970-х и 1980-х годах существовали методологии, опирающиеся на прослеживаемость (tracebility), и стремящиеся сократить системные требования вплоть до уровня юнитов. В общем, это NP-трудная (нелинейная полиномиальная) задача (если только вы не выполняете чисто процедурную декомпозицию), поэтому я очень скептичен в отношении всех, кто говорит, что способен её решить. В итоге, единственный вопрос, который следовало бы задавать каждому тесту: Если тест упадет, какое из бизнес-требований будет нарушено? В большинстве случаев, ответ: Я не знаю. Если вы не понимаете ценность теста, тогда, теоретически, он может иметь нулевую ценность для бизнеса. У теста есть стоимость: поддержка, время вычислений, администрирование и так далее. Значит, у теста может быть чистая отрицательная ценность. И это четвертая категория тестов, которые необходимо удалять. Такие тесты, не смотря на их способность что-то проверять, в действительности ничего не проверяют.

Если вы не можете объяснить, как сбой какого-либо юнит теста отражается на рисках, следует подумать о возможном удалении такого теста. При отсутствии формальных критериев корректности, существуют более эффективные техники устранения изъянов в качестве, такие как исследовательское тестирование и методы Монте-Карло. (Они великолепны, и я рассматриваю их как отдельную категорию в отличие от того, о чем рассказываю здесь.) Не используйте юнит тесты для подобных проверок.

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

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

1.6 Сложное - сложно

Существует следующая дилемма: большая часть интересных показателей о качестве определенных программ находиться в распределении результатов тестирования, несмотря на то, что традиционные подходы к статистике, всё же, предоставляют ложную информацию. Так, в 99,99% всех случаев тест может быть успешным, но однажды упав за десять тысяч раз, он убьет вас. Опять же, заимствуя аналогию из мира железа, для уменьшения вероятности ошибки до сколь угодно низкого уровня, вы можете всё проектировать с учетом заданной вероятности отказа или же провести анализ наихудшего случая (WCA). Специалисты по аппаратному обеспечению обычно используют WCA при проектировании асинхронных систем для защиты от сбоев в сигналах, выходящих за пределы проектных параметров: один сбой на 100 миллионов раз. В области аппаратного обеспечения, сказали бы, что коэффициент качества (FIT rate) такого модуля равняется 10 - десять отказов на триллион (Failures In a Trillion).

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

Приятно читать статью, проливающую свет на причину моего успеха (и остальной части моей команды). Возможно, Вы уже знаете, что я инженер по авионике, чья карьера началась с разработки встраиваемого программного обеспечения, и, отчасти, разработки оборудования. И вот с таким образом мышления, ориентированным на особенности работы оборудования, я начал тестировать свое программное обеспечение. (Команда состояла из четырех человек: 3-х инженеров-электриков из Делфтского университета (включая меня, в качестве специалиста по авионике) и одного инженера-программиста (из Гаагского университета). Мы были очень дисциплинированы в разработке систем безопасности для банков, пенитенциарных учреждений, пожарных, полицейских участков, служб экстренной помощи, химических заводов и т.д. В каждом из случаев всё должно было правильно заработать с первого раза.)

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

Большинство программистов убеждены, что построчное покрытие исходного кода, или, по крайней мере, покрытие ветвлений является вполне достаточным. Нет. С точки зрения теории вычислений, покрытие наихудшего случая означает анализ всевозможных комбинаций в последовательностях работы машинного языка, при котором гарантируется достижение каждой инструкции, а также - воспроизведение каждой возможной конфигурации битов данных в каждом из значений счетчика команд выполняемой программы. (Недостаточна и симуляция состояния среды выполнения только лишь для модуля или класса, содержащего тестируемую функцию или метод: как правило, любое изменение в каком-либо месте может проявиться в любом другом месте программы, а поэтому, потребует повторного тестирования всей программы. Формальное доказательство предложено в статье: Перри и Кайзера (Perry and Kaiser), Адекватное тестирование и объектно-ориентированное программирование (Adequate Testing and Objectoriented Programming), Журнал объектно-ориентированного программирования 2 (5), январь 1990 г., стр. 13). Даже взяв небольшую программу, мы уже попадаем в такое тестовое окружение, количество комбинаций в котором намного превышает количество молекул во Вселенной. (Мое определение понятия покрытие кода - это процент всех возможных пар, {Счетчик команд, Состояние системы}, воспроизводимых вашим набором тестов; все остальное - эвристика, которую, очевидно, вам сложно будет как-либо обосновать). Большинство выпускников бакалавриата смогут распознать проблему остановки (Halting Problem) в большинстве вариантов подобных задачах и поймут, что это невозможно.

1.7 Меньше - это больше или вы не шизофреник

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

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

Если у вас 200, 2000, или 10 000 тестов, вы не будете тратить время на тщательное исследование и (кхе-кхе) рефакторинг каждого из них каждый раз, когда тест падает. Самая распространенная практика, которую я наблюдал, работая в стартапе еще в 2005 году, - это просто переписать результат старых тестов (ожидаемый результат или результаты вычислений такого теста) новыми результатами. С психологической перспективы, зеленый статус - это вознаграждение. Современные быстрые машины создают иллюзию возможности замены мышления программиста; их скорость намекает на исключение моей необходимости мыслить. Ведь, в любом же случае, если клиент сообщит об ошибке, я, в свою очередь, сформулирую гипотезу о ее действительной причине, внесу изменения, исправляющие поведение системы, и, в результате, с легкостью смогу себя убедить, что функция, в которую я добавил исправление, теперь работает правильно. То есть я просто переписываю результат выполнения этой функции. Однако, подобное - просто лженаука, основанная на колдовстве, связь с которым - причинность. В таком случае, необходимо повторно запустить все регрессионные и системные тесты.

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

1.8 Вы платите за поддержку тестов и качество!

Суть в том, что код - это часть вашей системной архитектуры. Тесты - это модули. Тот факт, что кто-то может не писать тесты, не освобождает его от ответственности заниматься проектированием и техническим обслуживанием возрастающего количества модулей. Одна из методик, которую часто путают с юнит-тестированием, но использующая последнее в качестве техники - это разработка через тестирование (TDD). Считается, что она улучшает метрики сцепления и связности (coupling and coherence), хотя, эмпирические данные свидетельствуют об обратном (одна из статей, опровергающих подобное представление на эмпирических основаниях принадлежит Янзену и Саледиану (Janzen and Saledian), Действительно ли разработка через тестирование улучшает качество проектирования программного обеспечения? IEEE Software 25(2), март/апрель 2008 г., стр. 77 - 84.) Еще хуже то, что таким образом, в качестве запланированного изменения, вы уже вводите связанность (coupling) между каждым модулем и сопровождающими их тестами. У вас появляется необходимость относиться к тестам так же как и к системным модулям. Даже если вы удаляете их перед релизом, это никак не сказывается на необходимости их обслуживать. (Подобное удаление может быть даже достаточно плохой идеей, но об этом дальше.)

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

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

Превратите юнит-тесты в утверждения (assertions). Используйте их для обеспечения отказоустойчивости архитектуры высокодоступных систем. Это решает проблему поддержки множества дополнительных программных модулей, которые оценивают ход выполнения (программы) и проверяют правильность (её) поведения; это одна часть юнит-тестирования. Другая - это драйвер, выполняющий сам код: в этом отношении, рассчитывайте, на свои стресс-, интеграционные и системные тесты.

Почти последнее, существуют такие юнит-тесты, которые повторяют системные, интеграционные или другие виды тестов. На заре вычислений, когда компьютеры были медленными, вместо того, чтобы дожидаться запуска системных тестов, юнит-тесты предоставляли разработчику более быструю обратную связь о том, сломало ли их изменение код. Сегодня, когда появились более дешевые и мощные компьютеры, этот аргумент кажется менее убедительным. Каждый раз, внося изменения в свое приложение Scrum Knowsy, я тестирую его на системном уровне. Разработчики должны непрерывно интегрироваться и, так же непрерывно проводить тестирование системы, а не сосредотачиваться на своих юнит-тестах и откладывать интеграцию, даже на час. Так что избавляйтесь от юнит-тестов, которые дублируют то, что уже делают системные тесты. Если системный уровень обходится слишком дорого, создайте наборы интеграционных тестов. Рекс (Rex) считает, что следующим большим скачком в тестировании будет разработка таких юнит, интеграционных и системных тестов, которые устраняют случайные упущения и дублирование.

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

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

1.9 Это процесс, глупец или лихорадка зеленого статуса

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

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

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

Системные тесты почти сразу же погружают вас в подобную позицию размышления. Разумеется, вам все еще нужна будет более подробная информация, для этого на помощь приходит отладка (debugging). Отладка - это использование инструментов и устройств, помогающих изолировать ошибку. Отладка - не тестирование. Во-первых, она представляет собой ad-hoc (интуитивную активность) и, во-вторых, выполняется на основании последовательного перехода от ошибки к ошибке. Юнит тесты могут быть полезным инструментом отладки. На собственном опыте я обнаружил, что лучше всего работает комбинация различных инструментов, среди которых наиболее эффективными являются наборы с невалидными данными и доступ к глобальному контексту, включающий все значения данных и случайную трассировку стека.

1.10 Подводим итоги

Вернемся к моему клиенту из компании Sogeti. Вначале, я упоминал его высказывание:

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

Возможно, Ричард - это один из тех редких людей, которые знают, как думать самому, вместо того, чтобы позволять компьютеру думать за него - будь то проектирование системы или дизайн более низкого уровня. Я чаще наблюдаю подобную ситуацию в Восточноевропейских странах, где отсутствие общедоступного компьютерного оборудования заставляло людей мыслить. Просто не хватало компьютеров. Когда я впервые посетил Сербию в 2004 году, студенты из ФОН (факультета информатики) могли получить доступ к компьютеру для выхода в интернет один раз в неделю. Расплата за ошибку высока: если запуск вашего кода не отработает, придется ждать еще неделю, чтобы повторить свою попытку.

К счастью, я вырос именно в такой культуре программирования, мой код записывался на перфокартах, которые отдавались оператору для установки в очередь машины, а затем, через сутки, собирались результаты. Такой формат действительно заставлял вас или же задуматься - или же, потерпеть неудачу. У Ричарда из Sogeti было аналогичное воспитание: у них была неделя на подготовку кода и всего один час на его запуск. Всё должно было делаться правильно с первого раза. В любом случае, обдуманный проект должен оценивать возможные риски, связанные с затратами, и устранять их по одному в каждой итерации, уделяя особое внимание постоянно растущей ценности. Одна из моих любимых циничных цитат: Я считаю, что недели программирования и тестирования могут сэкономить мне часы планирования. Что меня больше всего беспокоит в культуре раннего провала (fail-fast), так это не столько понятие провала, сколько слово раннее. Много лет назад мой босс Нил Халлер мне сказал, что отладка - это не то, что вы делаете, сидя перед своей программой с отладчиком; это то, что вы делаете, откинувшись на спинку стула и глядя в потолок, или обсуждение ошибки с командой. Однако многие, якобы ярые приверженцы эджайл методологий, ставят процессы и JUnit выше людей и взаимодействий.

Лучший пример, услышанный мной в прошлом году, был от моей коллеги, Нэнси Гитинджи (Nancy Githinji), управлявшей вместе со своим мужем IT-компанией в Кении; сейчас они оба работают в Microsoft. Последний раз, посещая свой дом (в прошлом году), она познакомилась с детьми, которые проживают в джунглях и пишут программы. Они могут приезжать раз в месяц в город, чтобы получить доступ к компьютеру и апробировать свой код. Я хочу нанять этих детей!

Мне, как стороннику эджайла (да и просто из принципа), немного больно признавать, что Рекс оказался прав, как, впрочем-то это было и ранее , достаточно красноречиво сказав: В этой культуре раннего провала (fail fast) есть нечто небрежное, она побуждает швырнуть кучу спагетти на стену, особо даже не задумываясь отчасти, из-за чрезмерной уверенности в заниженных рисках, предоставляемых юнит-тестами. Культура раннего провала может хорошо работать при очень высокой дисциплине, подкрепленной здоровым скептицизмом, однако редко можно встретить такое отношение в динамичном IT-бизнесе. Иногда ошибки требуют обдумывания, а последнее требует больше времени, чем результаты, достигаемые ранним провалом. Как только что напомнила моя жена Гертруда: Никто не хочет, чтобы ошибки затягивались на долго

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

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

Пишите мне свои комментарии на jcoplien@gmail.com с копией Рексу вначале этого письма.

В заключение:

  • Сохраняйте регрессионные тесты до года, большинство из них должны быть тестами системного уровня, а не юнит-тестами.

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

  • Исключая предыдущее заявление, если у X есть определенная бизнес-ценность и вы можете протестировать X системным или же юнит-тестом, используйте системный: контекст - это всё.

  • Разрабатывайте тест более тщательно, чем код.

  • Превратите большинство юнит-тестов в утверждения (assertions).

  • Удалите тесты, которые за год ни разу не падали.

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

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

  • Будьте скромны в отношении способностей тестов. Тесты не улучшают качество: это делают разработчики.

Подробнее..

Из песочницы Принципы PDD Panic Driven Development

02.09.2020 18:14:51 | Автор: admin
Привет, Хабр! Уважаемые читатели, сие есть перевод замечательной статьи за авторством Мауро Фрезза. Надеюсь, он доставит вам истинное наслаждение и поддержит вас в курсе современных тенденций в методологиях разработки.

image

После того как прошла волна успеха методологий разработки из семейства Agile, проверку временем выдержали лишь немногие из них. Но среди них есть одна особая техника: PDD Panic Driven Development Разработка через панику.

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

Чем новее задача тем выше приоритет


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

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

Пишем ровно столько кода, сколько требуется для результата


Разработчики зарабатывают на жизнь написанием кода. Ошибки можно исправить только кодом. Обсуждение дизайна и UX только замедляет разработку. Поэтому поступаем так: Пишем решение, убеждаемся что фикс рабочий. Если всё ок, то проблема решена. Едем дальше.

Не надо торопиться с тестированием


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

Доверьтесь чувствам


Программирование это искусство. Неотъемлемой частью любого искусства являются инстинкты и интуиция. Слушай своё сердце. Пиши решение. Смелее выкатывай его. Только смелым улыбается удача.

Процесс должен приспособиться под вас


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

Всё исходит от менеджера


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

Заключение


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

Она находит применение в компаниях по всему миру и является фундаментом для гибкого и бескомпромиссного программирования.
Подробнее..

За счет чего TDD драйвит разработку

06.12.2020 16:18:24 | Автор: admin

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

Поэтому я не хотел писать еще одну статью с описанием техники Red-Green-Refactor. Мне хотелось взглянуть на TDD немного глубже и описать, как и почему TDD влияет на поведение человека.

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

Мои первые шаги вTDD

Я работаю web-разработчиком 12 лет. Первые 10 я выполнял задачи на php для CMS-систем Joomla и Bitrix. Как я сейчас вижу, до развития качества и практик чистого кода не особо то и доходило. Но мне удалось вырваться из западни работы с CMS и последние 2 года мой стек в основном это javascript (React).
За все эти года у меня никогда не было ни ментора, чтобы направить или объяснить, ни кумира, чтобы копировать поведение, стремясь стать лучше, не понимая, как это сделать. Немного обидно и, для кого-то, удивительно, но я познакомился с TDD совсем недавно, хотя многие из статей, которые я читаю, датируются 2013 годом. Честно говоря, я не понимаю, как это возможно, столько лет старательно работать и так много не знать о своей же профессии, но это факт.
И в итоге, моим ментором за последний год стал Скрамгайд, который, кроме описания процессов, делает еще и акцент на техническом качестве продукта. Большую часть базового понимания о стандартах разработки, в том числе о TDD, я получил из подготовки к сертификации Professional Scrum Developer на scrum.org.

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

В какой-то момент я оказался один на один с целью прочитать книгу Test Driven Development: By Example от Kent Beck. В тот момент у меня было некое понимание, что такое TDD, и оно преимущественно совпадало с коллегами, которые также что-то слышали о нем, но толком не пробовали. В двух словах, я думал, что TDDэто те же самые юнит тесты, только написанные до имплементации. Звучит немного отпугивающим и сложным, но мне понравилась идея. И я начал читать

В районе 50-ой страницы ко мне пришло озарение, насколько ложным и неправильным было мое прежнее понимание. Тесты, написанные при TDD,это другие тесты, категорически и совершенно другие тесты по их логике, по их коду, по их смыслу. Если вкратце, то такой тест не должен соответствовать и проверять требование задачи, его цельпроверить только следующий маленький шаг, которые разработчик собрался реализовать в ближайших строках кода в следующие 2515 минут. Пример, как это может выглядитExample of TDD by H. Koehnemann, и обратите внимание, что acceptance test пишется уже в самом конце.

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

И вот они:

Верхнеуровневый список задач (todolist)

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

Кент Бек на протяжении всей книги описывает этот процесс, как неотъемлемую его часть. И эта идея совершенно не нова. По меньшей мере, этот подход описывается как базовая составляющая менеджемент-системы GettingThingsDone. GTD утверждает, что уровень стресса резко уменьшится, а продуктивность возрастет, если человек освободит свой разум от запоминания текущих задач, перенесет их на внешний носитель и сфокусирует полную силу своего сознания на конкретную текущую задачу.

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

Внезапно появилась новая гениальная идея? Не переключайтесь на неё, отправьте её в список. Потом к ней вернётесь.

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

Test-First Thinking

Test-first мышлениеэто уже нечто большее чем техникаэто сдвиг в видении задач и подхода к их решению. Обычно, перед началом имплементации, разработчик задается вопросом как я реализую эту функцию?. Основная идея test-first подхода в том, что такой вопрос смещает фокус с задачи на имплементацию этой задачи. Это смещение может привести к выстраиванию воздушных замков, излишней преждевременной оптимизации, нарушения принципа о простоте из Agile манифеста, не говоря о конкретных YAGNI и KISS правилах разработки. Но даже если этого не произойдет и код не будет нарушать эти принципы, это все равно не ответит на вопрос как я узнаю, что я действительно достиг своей цели?.

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

И это именно то, что означает литера M в аббревиатуре S.M.A.R.T. постановке целей.

Но есть путь, который позволит избежать этой ловушкиTest-First Thinking. Не задавайтесь вопросом об имплементации. Спросите себя Как я смогу кому-то продемонстрировать выполненную задачу?, Как я могу протестировать, что все выполнено правильно?, Как я узнаю тот момент, когда работа сделана?. Вопросы такого типа провоцируют дополнительные мыслительные цепочки, которые позволят схватить нюансы, которые обычно теряются при мыслях только о реализации. Это поможет отделить зерна от плевел и более четко определить, что на самом деле нужно, а что сейчас избыточно. Это сместит фокус с написания кода на достижение результата, что в конечном счете и приводит чувству удовлетворения.

Понятная задача

Если перед человеком стоит задача Важная и задача Срочная, какую он начнет делать?
Какую обычно выбираете вы?

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

И здесь не идет речь о дисциплинированности или успешности конкретного человека. Это про то, как в целом работает человеческий мозг.

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

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

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

Правильное наименование

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

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

2. Название теста должно начинаться с глагола.

Пример получше:

describe(Функция factorial, () => {  it(возвращает 0 при отрицательном входном параметре, () => {     })})

Пример похуже:

describe(factorial, () => {  it(проверка на 0, () => {     })})

Эти правила опять же из описания GTD. Конкретно я об это почерпнул из Джедайских техник М. Дорофеева (Глава 3).

Прерывания

Сейчас (и уже давно) принято считать и громогласно говорить о высокой стоимости прерывания разработчика от рабочего процесса. Обычно речь идет о воздействии менеджеров на мыслительный процесс программиста. Например, THIS IS WHY YOU SHOULDNT INTERRUPT A PROGRAMMER и The Cost of Interruption for Software Developers.

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

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

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

Научение через обратнуюсвязь

Человек не может учиться эффективно без получения обратной связи на его действия. Это базовый психологический момент природы человека в принципе (Как стать лучшим в своем деле?А. Курпатов) и это же наиболее эффективный способ обучения.

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

Тест coverage

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

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

Рефакторинг

Я не буду уделять много внимания на вполне понятный профит для рефакторинга при наличии качественных автотестов (Начинаем писать тесты (правильно)Кирилл Мокевнин [Хекслет]). Это действительно сильно уменьшает дискомфорт и страх и позволяет разработчику более удобно и легко перелопачивать уже написанный код. Но про это говорится почти всегда, когда речь заходит про TDD, и, честно говоря, в контексте рефакторинга я не вижу большой разницы между TDD и тестами, написанными после имплементации.

Дисциплинированный разработчик

На мой субъективный взгляд, самая ключевая ценность TDD в том, что при его использовании разработчик неосознанно использует классические приемы самоуправления применительно к процессу написания кода. А это, определенно, требует дисциплины. Конечно, любой может быть организованным в работе, вне зависимости от использования TDD. Но тот, кто использует TDD в правильной интерпретации, автоматически будет организованным и дисциплинированным хотя бы применительно к написанию кода. Я считаю это очень важной характеристикой в текущее время печенек и PS-ок в офисе (до 2020) и удаленной работы в 2020.

Минусы TDD

Простите, но в контексте вышеописанного я их не вижу.

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

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

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

Итог

Да никакого итога. Мысли вслух. Скомпоновал как смог.
Если кто-то нашел в этой статье пищу для размышлений, то я доволен. Стремление к горстке признания это ведь так по-человечески.

Подробнее..

Перевод Двигайся быстрее и ломай преграды? Не так быстро, когда дело касается встраиваемых систем

14.12.2020 06:13:47 | Автор: admin

Шон Престридж старший инженер по применению (FAE), руководитель группы FAE американского подразделения IAR Systems в своей статье Move fast and break things? Not so fast in embedded, рассказывает о специфике разработки программного обеспечения для встраиваемых систем, уделяя особое внимание вопросам качества кода и тестирования.


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


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


Означает ли это что нельзя применять методы быстрой разработки приложений (RAD Rapid Application Development) для встраиваемых систем? Конечно нет, такие методы можно использовать, но нужно быть очень аккуратным в том, как это делать.


Спешка не делает нас ни быстрее, ни продуктивнее


Быстрая разработка подразумевает что качество кода уходит на второй план, обеспечивая тем самым быструю доставку кода. Иногда это называют синдромом WISCY (произносится как виски): почему еще никто не программирует? Это также означает что тестирование кода не делается вообще или делается формально. Во всяком случае, и то и другое может вызвать проблемы в проекте, поэтому следует придерживаться лучших практик, гарантирующих качество кода.


Когда разработчики спешат добавить новую функциональность (или исправить ошибки) они, как правило, пропускают любое интеграционное тестирование с остальной частью системы и проверяют только свой код лишь за рабочим столом, выполняя несколько специально созданных для этой цели тестов. Причина такого поведения кроется в культуре спешки, которая требует выпускать код как можно быстрее. Как говорит Леми Эргин: Спешка не делает нас ни быстрее, ни продуктивнее. Она усиливает стресс и отвлекает внимание. Нам нужны креативность, эффективность и внимательность [1]. Быстрое развертывание программного обеспечения начинается с качества кода.


Разработка ПО для встраиваемых систем требует масштабируемости


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


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


Планирование может показаться излишним если смотреть на срок окончание проекта, а это чревато тем, что Келси Андерсон называет ковбойским программированием. В результате, ради целесообразности снижается качество ПО и со временем возрастает сложность системы за счёт высокой связности и увеличения технического долга. В итоге разработка фактически замедляется, а проект становится более дорогим [2]. Как утверждает Леми Эргин: Без качественной кодовой базы невозможно быть гибким [1].


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

Всё начинается с качества кода ведь это так просто


Как можно улучшить качество кода, когда не хватает времени даже на то, чтобы хоть как-то его написать, для того чтобы уложиться в график? К счастью есть такие стандарты как MISRA, CWE, CERT, которые могут в этом помочь. Эти стандарты были рассмотрены в предыдущей статье. Главное, они способствуют безопасной и надёжной практике программирования, избегая как опасного программирования того или иного поведения функций, так и дыр в стандартах языков Си и С++.


Используя инструменты анализа кода, которые могут автоматически сканировать код на наличие тех или иных отклонений от приведенных стандартов, можно быстро находить и исправлять проблемы, пока разработчик проверяет свой код за рабочим столом. Другими словами, разработчик получает мгновенную обратную связь по своему коду, всё ещё находясь в процессе его написания. В результате чего, разработчик исправляет ошибки более чем на 50 процентов чаще, чем если бы такая обратная связь поступала позже со сервера сборки [3]. Следование приведенным стандартам резко повышает качество кода, что, в свою очередь, снижает технический долг решения. Соответственно меньше ошибок добавляется в код, а те ошибки, которые есть, могут быть найдены и исправлены гораздо быстрее. Кроме того, подобный структурированный подход в программировании упрощает добавление функционала, а значит это можно делать быстрее. Но это лишь половина дела, потому что даже с качественной кодовой базой, всё равно нужно правильно тестировать.


Важно сочетание качества кода и тестирования


В своей основополагающей работе Совершенный код, Стив Макконнел рассуждает о связи между тестированием и качеством кода:


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

Обычно тестирование выполняется вручную, но с новыми инструментами, которые доступны командам разработчиков, становится возможным автоматизировать этот процесс. Несмотря на то, что эффективность таких инструментов варьируется от производителя к производителю, они всё же могут делать удивительные вещи, такие как: непрерывная проверка на соответствие техническим требованиям (соответствует ли код техническим требованиям спецификации по настоящему или это всего лишь позолота), модульное тестирование (хорошо ли конкретный программный модуль выполняет свои функции), интеграционное тестирование (все ли модули хорошо взаимодействуют друг с другом) и многое другое. Привлекательность автоматизированного тестирования может быть чем-то вроде сирены для разработчиков: Министерство обороны США предупреждает, что команды разработчиков должны быть реалистичны в своих подходах к автоматизированному тестированию с точки зрения того, что такое тестирование действительно выполнимо и что его успешное прохождение говорит о готовности кода к выпуску [5].


Две самые распространённые проблемы в разработке ПО для встраиваемых систем связаны с условиями гонки (когда получаются разные результаты в зависимости от того, какое условие завершилось первым) и проблемами инициализации. Обе проблемы могут привести к ошибкам, которые очень редко проявляют себя. Кроме того, такие ошибки трудно воспроизвести и, следовательно, трудно исправить и проверить. Автоматизированное тестирование, как правило, плохо справляется с поиском подобного рода ошибок и зачастую требуется творческий подход для того, чтобы найти способы проявить эти проблемы. Один из классических способов это одновременно нажать несколько кнопок на устройстве. Получаются ли разные результаты если нажимать кнопки одновременно (в пределах одного машинного цикла)? А что будет если нажать одну кнопку, а затем нажать другую до того, как завершится подпрограмма обработки прерывания, вызванная первым нажатием? Обычно это делается с помощью ручного тестирования и, хотя может потребоваться много попыток для того, чтобы получить правильные комбинации, люди тем не менее невероятно хороши в этом деле.


Одна из мантр обеспечения качества заключается в том, чтобы тестировать раньше, тестировать чаще для того, чтобы понять, насколько процессы разработки выдерживают тщательную проверку. Это позволяет предпринимать корректирующие меры на раннем этапе разработки, что может сэкономить немало времени и денег. По оценкам IBM, если стоимость исправления ошибки стоит 100$ на этапе сбора требований к проекту, то исправить такую ошибку во время обычного цикла тестирование-и-исправление будет стоит уже около 1500$, а на этапе производства 10000$ [6]. Вы быстро понимаете, что хотите найти как можно больше ошибок и как можно раньше. Это привело к появлению подхода Разработка через тестирование (Test-Driven Development TDD), при котором создаются тесты из требований технического задания и обычно это происходит одновременно с началом проектирования. Идея заключается в том, что написанный и улучшенный код должен успешно проходить эти тесты, основанные на требованиях. Поэтому получаем повторяющиеся, очень короткие циклы разработки: пишем код, тестируем, повторяем пока программный модуль не заработает; затем переходим к следующему модулю. При этом тестирование первого модуля и его интеграция с другими модулями не прекращается. Таким образом, замедляя разработку вначале, мы ускоряем её к концу проекта.


Двигайся быстро, уделяя особое внимание качеству кода


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


Список ссылок


  1. https://www.infoq.com/articles/slow-down-go-faster/
  2. https://www.targetprocess.com/articles/speed-in-software-development/
  3. https://cacm.acm.org/magazines/2018/4/226371-lessons-from-building-static-analysis-tools-at-google/fulltext
  4. Steve McConnell, Code Complete, Second Edition (Microsoft Press, 2009), 501.
  5. https://www.afit.edu/stat/statcoe_files/0214simp%202%20AST%20IG%20for%20Managers%20and%20Practitioners.pdf
  6. https://crossbrowsertesting.com/blog/development/software-bug-cost/
Подробнее..

Перевод Тестирование назад к основам PuppeteerMocha Совершенствуйте код с помощью тестового покрытия

09.07.2020 20:08:37 | Автор: admin

Перевод статьи подготовлен в преддверии старта курса Автоматизация тестирования на JavaScript.




Меня несколько раз спрашивали о разнице между инженером по обеспечению качества (QA Quality Assurance) и тестером (QC Quality Control), и я понял, что даже если люди, разрабатывающие программное обеспечение, путают такие простые понятия, то что уже говорить про остальных? Но по большому счету, вы понимаете, что им все равно, и это их право!


Людям просто нужны качественные продукты! Менеджеры хотят иметь программное обеспечение без багов [1] наилучшего качества, пользователям нужны качественные приложения, а этого можно достигнуть путем неустанного тестирования [2]. Но что это значит? Что такое качество (Quality)? Все дело в удовлетворении настолько тривиально! Мера качества это счастье конечных пользователей! Достижение этой абстракции становится настоящим испытанием для команды [3].


При разработке любого продукта, особенно программного обеспечения без тестирования, мы сталкиваемся с рисками [4], а они, с большой долей вероятности, сопряжены с потерей средств [5]. Так что, если вы не большой фанат тестирования, но для вас важно качество, то я предлагаю вам подумать еще раз и дать процессам обеспечения качества делать свое дело. Это относится ко всем членам команды и особенно к инженерам, которые имеют отношение к тест дизайну [6] инженерам по обеспечению качества.


Таким образом, посыл, что человек должен быть удовлетворен качественным приложением, подкрепляется подходами, уже сформировавшимися в лучшие практики [7]. Однажды занявшись тестированием, вы узнаете о существовании различных типов тестирования [8] и документации по тестированию (например, план тестирования). Затем вы сможете обрабатывать тест кейсы [9] на основе разных техник тест дизайна [10]. В конце концов, вы откроете для себя автоматизацию и к тому времени поймете разницу между QA и QC [11].


Глоссарий:


  1. несоответствие между фактическим и ожидаемым поведением
  2. полной проверки продукта путем верификации, валидации на основе спецификаций для обнаружения проблем
  3. люди, которые разрабатывают приложения, такие как дизайнеры, программисты, тестировщики
  4. (а) возможность негативного исхода (в соответствии с Кембриджским словарем);
    (b) действия в надежде на позитивных исход (в соответствии со словарем Google)
  5. (a) 125 миллионов пробников почвы Марса, потерянных из-за простой математической ошибки
    (b) много человеческих ошибок, приведших к крушению Boeing 737 Max
  6. пирамида тестов, фреймворк для разработки набора тестов, который оптимизирует скорость выполнения тестов с уверенностью, что ваше приложение работает должным образом
  7. семь принципов тестирования на International Software Testing Qualifications Board
  8. различные типы тестирования программного обеспечения от Atlassian
  9. этапы воспроизведения с ожидаемыми результатами в соответствии с требованиями приложения
  10. общие техники тестирования, которые вы должны знать
  11. ищите ответ здесь

Книги:


  • Тестирование Дот Ком, или Пособие по жестокому обращению с багами в интернет-стартапах, Савин Р. 2007
  • Continuous delivery. Практика непрерывных апдейтов, Эберхард В. 2017
  • Software Testing Automation Tips, Алпаев Г. 2017

[Puppeteer][Mocha] Проапгрейдите свой код с помощью тестового покрытия.


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


С момента релиза Puppeteer сквозное тестирование (end-2-end) стало быстрым и надежным способом тестирования функционала. Большинство вещей, которые вы можете сделать вручную в браузере, можно сделать с помощью Puppeteer. Более того, Chrome без графики снижает нагрузку на производительность, а нативный доступ к протоколу DevTools делает Puppeteer просто потрясающим инструментом. Представьте, что каждый раз, когда мы разрабатываем интерфейс, мы просто проверяем окончательный вид в браузере без TDD мы сталкиваемся с мороженкой-антипатерном пирамиды тестов.



Сверху вниз: Ручные тесты / Автоматизированные GUI-тесты / Интеграционные тесты / Юнит-тесты


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


Настройка


У меня есть полная пошаговая README.md инструкция, основанная на простом проекте, который я форкнул и снабдил его многофункциональным тестовым апгрейдом в целях демонстрации (или чтобы выпендриться). Итак, если у вас есть еще один, пожалуйста:


1) Установите зависимости в корне


npm i puppeteer mocha puppeteer-to-istanbul nyc -D

2) Предоставьте свой экземпляр на конечной точке (мое простое решение для *.html http-сервер)


3) Создайте каталог test и заполните {yourFeature}_test.js следующим подходящим шаблоном (заполните хуки before и after), попробуйте расширить его с помощью специфичных для проекта селекторов и поведений:


const puppeteer = require('puppeteer');const pti = require('puppeteer-to-istanbul');const assert = require('assert');/** * ./test/script_test.js * @name Feature testing * @desc Create Chrome instance and interact with page. */let browser;let page;describe('Feature one...', () => {    before(async () => {        // Создание экземпляра браузера        browser = await puppeteer.launch()        page = await browser.newPage()        await page.setViewport({ width: 1280, height: 800 });        // Подключите покрытие JavaScript и CSS        await Promise.all([            page.coverage.startJSCoverage(),            page.coverage.startCSSCoverage()          ]);        // Конечная точка для эмуляции изолированного окружения        await page.goto('http://localhost:8080', { waitUntil: 'networkidle2' });    });    // Первый тестовый набор    describe('Visual regress', () => {        it('title contain `Some Title`', async () => {            // Настройка            let expected = 'Some Title';            // Выполнение            let title = await page.title();            // Проверка            assert.equal(title, expected);        }).timeout(50000);    });    // Второй тестовый набор    describe('E2E testing', () => {        it('Some button clickable', async () => {            // Настройка            let expected = true;            let expectedCssLocator = '#someIdSelector';            let actual;            // Выполнение            let actualPromise = await page.waitForSelector(expectedCssLocator);            if (actualPromise != null) {                await page.click(expectedCssLocator);                actual = true;            }            else                actual = false;            // Проверка            assert.equal(actual, expected);        }).timeout(50000);    // Сохранить покрытие и закрыть контекст браузера    after(async () => {        // Отключить покрытие JavaScript и CSS        const jsCoverage = await page.coverage.stopJSCoverage();        await page.coverage.stopCSSCoverage();        let totalBytes = 0;        let usedBytes = 0;        const coverage = [...jsCoverage];        for (const entry of coverage) {            totalBytes += entry.text.length;            console.log(`js fileName covered: ${entry.url}`);            for (const range of entry.ranges)                usedBytes += range.end - range.start - 1;        }        // вывести в лог исходное байтовое покрытие        console.log(`Bytes used: ${usedBytes / totalBytes * 100}%`);        pti.write(jsCoverage);        // Закрыть экземпляр браузера        await browser.close();    });});

Выполнение


  1. Запустите описанный выше тест для сценариев в конечной точке с помощью команды mocha
  2. Получите покрытие, собранное при выполнении теста с nyc report.
  3. Я предлагаю вам использовать ваш package.json с помощью следующих сценариев, что делает его очень простым для выполнения задач, таких как npm test или npm run cover

 "scripts": {    "pretest": "rm -rf coverage && rm -rf .nyc_output",    "test": "mocha --timeout 5000 test/**/*_test.js",    "server": "http-server ./public",    "coverage": "nyc report --reporter=html"  },

Покрытие


Мой проект покрыт на приблизительно 62%



Мы можем получить отчет в виде html и посмотреть поближе.



Вы можете видеть, что Branches и Functions покрыты на 100%. В то время как я тестировал функцию покрытия Puppeteer (типа Coverage devTool) я создал этот багрепорт.



[Bug] Неверные ветви попадают в статистику покрытия #22
Размещено storenth 27 декабря 2018
Когда готов nyc report --reporter=htm, я пытался посмотреть на ./coverage/index.html и обнаружил большой дефект в числе покрытия Branch, оно всегда равно 100%. Чтобы изучить эту проблему, я предлагаю клонировать этот простой репозиторий для локального воспроизведения.


Холивар Unit против E2E


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


Внесите свой вклад


Я настоятельно рекомендую уделить некоторое время и ознакомиться с моим github-working-draft проектом прежде, чем вы наломаете дров. Я буду признателен за любое сотрудничество и обратную связь. Не стесняйтесь обращаться ко мне с любыми вопросами.




Узнать подробнее о курсе Автоматизация тестирования на JavaScript



Подробнее..

Модульное тестирование, детальное рассмотрение параметризованных тестов. Часть I

04.11.2020 12:10:51 | Автор: admin
Доброго времени суток, коллеги.
Я решил поделиться своим видением на параметризованные юнит-тесты, как делаем это мы, и как возможно не делаете(но захотите делать) вы.
Хочется написать красивую фразу, о том что тестировать надо правильно, и тесты это важно, но до меня сказано и написано уже много материала, я лишь попробую резюмировать и осветить то, что по моему мнению людьми редко используется(понимается), на что в основном задвигается.

Основная цель статьи показать, как можно(и нужно) перестать захламлять свой юнит-тест кодом для создания объектов, и как декларативно создать тестовые данные, если одних mock(any()) не хватает, а таких ситуаций полно.

Создадим maven проект, добавим в него junit5, junit-jupiter-params и mokito

Чтоб не было совсем скучно начнем писать сразу с теста, как любят апологеты TDD, нам нужен сервис, который мы будем декларативно тестировать, подойдет любой, пускай это будет HabrService.

Создадим тест HabrServiceTest. В поле класса теста добавим ссылку на HabrService:
public class HabrServiceTest {    private HabrService habrService;    @Test    void handleTest(){    }}


создадим сервис через ide(легким нажатием шортката), добавим на поле аннотацию @InjectMocks.

Приступаем непосредственно к тесту: HabrService в нашем небольшом приложении будет иметь один единственный метод hande(), который будет принимать один единственный аргумент HabrItem, и теперь наш тест выглядит так:
public class HabrServiceTest {    @InjectMocks    private HabrService habrService;    @Test    void handleTest(){        HabrItem item = new HabrItem();        habrService.handle(item);    }}


Добавим в HabrService метод handle(), который будет возвращать id нового поста на хабре после его модерации и сохранения в БД, и принимает тип HabrItem, так же создадим наш HabrItem, и теперь тест компилируется, но падает.

Дело в том что мы добавили проверку, на ожидаемое возвращаемое значение.
public class HabrServiceTest {    @InjectMocks    private HabrService habrService;    @BeforeEach    void setUp(){        initMocks(this);    }    @Test    void handleTest() {        HabrItem item = new HabrItem();        Long actual = habrService.handle(item);        assertEquals(1L, actual);    }}


Также, я хочу убедиться, что в ходе вызова метода handle(), были вызваны ReviewService и PersistanceService, вызвались они строго друг за другом, отработали ровно по 1 разу, и никакие другие методы уже не вызывались. Иными словами вот так:
public class HabrServiceTest {    @InjectMocks    private HabrService habrService;    @BeforeEach    void setUp(){        initMocks(this);    }    @Test    void handleTest() {        HabrItem item = new HabrItem();                Long actual = habrService.handle(item);                InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);        inOrder.verify(reviewService).makeRewiew(item);        inOrder.verify(persistenceService).makePersist(item);        inOrder.verifyNoMoreInteractions();        assertEquals(1L, actual);    }}


Добавим в поля класса класса reviewService и persistenceService, создадим их, добавим им методы makeRewiew() и makePersist() соответственно. Теперь все компилируется, но конечно же тест красный.
В контексте данной статьи, реализации ReviewService и PersistanceService не так уж важны, важна реализация HabrService, сделаем его чуть интересней чем он есть сейчас:
public class HabrService {    private final ReviewService reviewService;    private final PersistenceService persistenceService;    public HabrService(final ReviewService reviewService, final PersistenceService persistenceService) {        this.reviewService = reviewService;        this.persistenceService = persistenceService;    }    public Long handle(final HabrItem item) {        HabrItem reviewedItem = reviewService.makeRewiew(item);        Long persistedItemId = persistenceService.makePersist(reviewedItem);        return persistedItemId;    }}


и с помощью конструкций when().then() замокируем поведение вспомогательных компонентов, в итоге наш тест стал вот таким и теперь он зеленый:
public class HabrServiceTest {    @Mock    private ReviewService reviewService;    @Mock    private PersistenceService persistenceService;    @InjectMocks    private HabrService habrService;    @BeforeEach    void setUp() {        initMocks(this);    }    @Test    void handleTest() {        HabrItem source = new HabrItem();        HabrItem reviewedItem = mock(HabrItem.class);        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);        Long actual = habrService.handle(source);        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);        inOrder.verify(reviewService).makeRewiew(source);        inOrder.verify(persistenceService).makePersist(reviewedItem);        inOrder.verifyNoMoreInteractions();        assertEquals(1L, actual);    }}


Макет для демонстрации мощи параметризованных тестов готов.

Добавим в нашу модель запроса к сервису HabrItem поле с типом хаба, hubType, создадим enum HubType и включим в него несколько типов:
public enum HubType {    JAVA, C, PYTHON}


а модели HabrItem добавим геттер и сеттер, на созданное поле HubType.
Предположим, что в недрах нашего HabrService спрятался switch, который в зависимости от типа хаба делает с запросом неведомое что-то, и в тесте мы хотим протестировать каждый из кейсов неведомого, наивная реализация метода выглядела бы так:
            @Test    void handleTest() {        HabrItem reviewedItem = mock(HabrItem.class);        HabrItem source = new HabrItem();        source.setHubType(HubType.JAVA);        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);        Long actual = habrService.handle(source);        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);        inOrder.verify(reviewService).makeRewiew(source);        inOrder.verify(persistenceService).makePersist(reviewedItem);        inOrder.verifyNoMoreInteractions();        assertEquals(1L, actual);    }


Можно сделать ее чуть красивей и удобней, сделав тест параметризированным и добавить в него в качестве параметр случайное значение из нашего enum, в итоге декларация теста станет выглядеть так:
@ParameterizedTest    @EnumSource(HubType.class)    void handleTest(final HubType type) 


красиво, декларативно, и все значения нашего enum обязательно будут задействованы при каком-нибудь очередном запуске тестов, у аннотации есть параметры, можем добавлять стратегии на include, exclude.

Но возможно я вас не убедил, что параметризованные тесты это хорошо. Добавим в
исходный запрос HabrItem новое поле editCount, в которое будет записано количество тысяч раз, которое пользователи Хабра редактируют свою статью, перед тем как запостить, чтоб она вам хоть немного понравилась, и положим что где то в недрах HabrService есть какая то логика, которая делает неведомое что-то, в зависимости от того, насколько попыток автор постарался, что если я не хочу писать 5 или 55 тестов на все возможные варианты editCount, а хочу протестировать декларативно, и где то в одном месте сразу обозначить все значения которые я хотел бы проверить. Нет ничего проще, и воспользовавшись api параметризованных тестов получим в декларации метода что то такое:
    @ParameterizedTest    @ValueSource(ints = {0, 5, 14, 23})    void handleTest(final int type) 


Налицо проблема, мы хотим собирать в параметрах тестового метода сразу два значения декларативно, можно использовать еще один прекрасный метод параметризованных тестов @CsvSource, отлично подойдет для того чтоб протестировать несложные параметры, с несложным выходным значением(крайне удобен в тестировании утилитных классов), но что если объект станет гораздо сложней? Скажем, в нем будет порядка 10 полей, причем не только примитивы и джава-типы.
На помощь приходит аннотация @MethodSource, наш тестового метода стал ощутимо короче и в нем нет больше сеттеров, а источник входящего запроса подается в тестовый метод параметром:
    @ParameterizedTest    @MethodSource("generateSource")    void handleTest(final HabrItem source) {        HabrItem reviewedItem = mock(HabrItem.class);        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);        Long actual = habrService.handle(source);        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);        inOrder.verify(reviewService).makeRewiew(source);        inOrder.verify(persistenceService).makePersist(reviewedItem);        inOrder.verifyNoMoreInteractions();        assertEquals(1L, actual);    }

в аннотации @MethodSource указана строка generateSource, что это? это название метода, который соберет для нас нужную модель, его декларация будет выглядеть так:
   private static Stream<Arguments> generateSource() {        HabrItem habrItem = new HabrItem();        habrItem.setHubType(HubType.JAVA);        habrItem.setEditCount(999L);                return nextStream(() -> habrItem);    }


для удобства формирование стрима аргументов nextStream я вынесес в отдельный утилитный тестовый класс
public class CommonTestUtil {    private static final Random RANDOM = new Random();    public static <T> Stream<Arguments> nextStream(final Supplier<T> supplier) {        return Stream.generate(() -> Arguments.of(supplier.get())).limit(nextIntBetween(1, 10));    }    public static int nextIntBetween(final int min, final int max) {        return RANDOM.nextInt(max - min + 1) + min;    }}

Теперь в при запуске теста, в параметр тестового метода декларативно будет добавляться модель запроса HabrItem, причем запускаться тест будет столько раз, сколько аргументов сгененрирует наша тестовая утилита, в нашем случае от 1 до 10.

Это может быть особо удобно, если модель в стриме аргументов собирается не хардкодом, как в нашем примере, а с помощью рандомайзеров.(да здравствует плавающие тесты, но ведь если они есть есть и проблема).
По моему уже все супер, в тесте теперь описано только поведение наших заглушек, и ожидаемые результаты.
Но вот незадача, в модель HabrItem добавляется новое поле, text, массив строк, которое может быть очень большим или не очень, неважно, главное то, что мы не хотим захламять наши тесты, нам не нужны рандомные данные, мы хотим строго определенную модель, с конкретными данными, собирать ее в тесте или где либо еще мы не хотим. Было бы круто, если бы можно было взять тело json запроса откуда угодно, например из постмана, сделать на его основе моковый файл и в тесте формировать модель декларативно, указав лишь путь к json файлу с данными.

Отлично. Используем аннотацию @JsonSource, которая будет принимать параметр path, с относительным путем к файлу и целевой класс. Черт! В параметризованных тестах нет такой аннотации, а хотелось бы.
Давайте напишем сами.
Обработкой всех аннотаций идущих в комплекте с @ParametrizedTest в junit занимаются ArgumentsProvider, мы напишем свой собственный JsonArgumentProvider:
public class JsonArgumentProvider implements ArgumentsProvider, AnnotationConsumer<JsonSource> {    private String path;    private MockDataProvider dataProvider;    private Class<?> clazz;    @Override    public void accept(final JsonSource jsonSource) {        this.path = jsonSource.path();        this.dataProvider = new MockDataProvider(new ObjectMapper());        this.clazz = jsonSource.clazz();    }    @Override    public Stream<Arguments> provideArguments(final ExtensionContext context) {        return nextSingleStream(() -> dataProvider.parseDataObject(path, clazz));    }}

MockDataProvider, это класс, для парсинга моковых json файлов, его реализация крайне простая:

public class MockDataProvider {    private static final String PATH_PREFIX = "json/";    private final ObjectMapper objectMapper;    @SneakyThrows    public <T> List<T>  parseDataList(final String name, final Class<T> clazz) {        return objectMapper.readValue(                new ClassPathResource(PATH_PREFIX + name).getInputStream(),                objectMapper.getTypeFactory().constructCollectionType(List.class, clazz)        );    }    @SneakyThrows    public <T> T parseDataObject(final String name, final Class<T> clazz) {        return objectMapper.readValue(new ClassPathResource(PATH_PREFIX + name).getInputStream(), clazz);    }}


Моковый провайдер готово, провайдер аргументов для нашей аннотации тоже, осталось добавить саму аннотацию
/** * Source-аннотация для параметризированных тестов, * использует в качестве источника json-файл */@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})@Retention(RetentionPolicy.RUNTIME)@ArgumentsSource(JsonArgumentProvider.class)public @interface JsonSource {    /**     * Путь к json-файлу, по умолчанию classpath:/json/     *     * @return относительный путь к моковому файлу     */    String path() default "";    /**     * Целевой тип, к которому будет приведен аргумент в результирующем стриме     *     * @return целевой тип     */    Class<?> clazz();}


Ура. Наша аннотация готова к употреблению, декларация тестового метода станет такой:

@ParameterizedTest    @JsonSource(path = MOCK_FILE_PATH, clazz = HabrItem.class)    void handleTest(final HabrItem source)


в моковом json мы можем наплодить сколь угодно и очень быстро пачку нужным нам объектов, и нигде отныне нет отвлекающего от сути теста кода, по формированию тестовых данных, конечно часто можно обойтись моками, но далеко не всегда.

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

p.s. Статья не является покушением на знание TDD концепций, хотелось накидать тестовые данные походу повествования, чтоб было чуть понятней и интересней.
Подробнее..
Категории: Java , Testing , Junit5 , Tdd

Категории

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

  • Имя: Макс
    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