TDD

Пример разработки «Крестиков‑ноликов» по TDD

Введение 🔗

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

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

Стандартный цикл разработки состоит из трёх этапов и занимает 10–15 минут.

Схема цикла разработки по методологии TDD
Изображение взято с сайта codedream.me

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

Второй этап — зелёная зона. На нём реализуется функция, которая проходит этот тест. Цикл короткий, поэтому реализация должна быть максимально простой.

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

В этой статье мы напишем игру «Крестики‑нолики» на JS, используя практику TDD.

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

Это объёмная статья. Возможно, вам захочется сразу перейти к результатам.

Структура проекта и инструменты 🔗

Для разработки игры мы будем использовать:

  • Jest для написания и запуска тестов;
  • JSDOM для проверки функций, работающих с DOM;
  • Webpack для сборки проекта.
package.json
{ "scripts": { "test": "NODE_OPTIONS=--experimental-vm-modules npx jest", "build": "webpack" }, "devDependencies": { "jest": "^27.4.7", "jsdom": "^11.12.0", "webpack": "^5.67.0", "webpack-cli": "^4.9.2" } }

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

webpack.config.js
import webpack from 'webpack' import path from 'path' export default [{ name: 'client', mode: 'production', context: path.resolve(), entry: { javascript: ['./src/index.js'] }, output: { filename: './js/bundle.js', path: path.resolve('dist'), }, }]

Первый тест 🔗

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

Начнём с метода, который возвращает начальное состояние. Начальным состоянием будет массив размером 3×3 с пустыми строками в клетках.

test/game.js
describe('Game', () => { test('Should return empty game board', () => { const game = new Game() const board = game.getState() expect(board).toEqual([ ['', '', ''], ['', '', ''], ['', '', ''] ]) }) })

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

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

Нам нужно дополнить код, создав этот класс. Создадим его прямо в коде теста.

Теперь тест упадёт по правильной причине: null !== начальному состоянию игры.

test/game.js
+ class Game { + getState() { + return null + } + }

Идём к зелёной зоне 🔗

Когда мы создали отказной тест, мы оказались в красной зоне. Теперь наша задача — пройти этот тест с как можно меньшими затратами.

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

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

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

Пока что getState всегда возвращает начальное состояние. К этому методу мы вернёмся позже.

test/game.js
class Game { getState() { - return null + return [ + ['', '', ''], + ['', '', ''], + ['', '', ''] + ] } }

Рефакторинг 🔗

Теперь у нас есть тесты, и мы можем приступить к рефакторингу кода.

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

Также вынесем создание экземпляра класса в beforeEach, который вызывается перед каждым тестом. Так возможное изменение конструктора затронет только одно место.

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

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

src/Game.js
export default class Game { getState() { return [ ['', '', ''], ['', '', ''], ['', '', ''] ] } }
test/game.js
import Game from '../src/Game' const initialGameBoard = [ ['', '', ''], ['', '', ''], ['', '', ''] ] let game beforeEach(() => { game = new Game() }) describe('Game', () => { test('Should return empty game board', () => { const board = game.getState() expect(board).toEqual(initialGameBoard) }) })

Тестируем ход игрока 🔗

Теперь напишем метод для обработки хода игрока.

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

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

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

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

Чтобы тест падал с ожидаемой причиной, создаём пустой метод acceptUserMove в классе игры.

test/game.js
+ test('Writes user\'s symbol in top left cell', () => { + const x = 0, y = 0 + + game.acceptUserMove(x, y) + const board = game.getState() + + expect(board[x][y]).toEqual('×') + })
src/Game.js
+ acceptUserMove(x, y) { + + }

Обрабатываем ход игрока 🔗

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

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

Также изменим getState, чтобы он возвращал не начальное состояние, а текущее.

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

Проверяем тесты и переходим к рефакторингу.

src/Game.js
export default class Game { + constructor() { + this._board = [ + ['', '', ''], + ['', '', ''], + ['', '', ''] + ] + } + getState() { - return [ - ['', '', ''], - ['', '', ''], - ['', '', ''] - ] + return this._board } + acceptUserMove(x, y) { + this._board[0][0] = '×' } }

Снова рефакторинг 🔗

Первым делом вынесем '×' в константы как в тестах, так и в коде класса.

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

Дальше обратим внимание на acceptUserMove: в нём this._board[0][0] явно относится к внутренней реализации класса. Вынесем это действие во внутренний метод _updateBoard и вызовем его внутри acceptUserMove.

Проверяем, не сломалось ли что‑то по дороге.

test/game.js
import Game from '../src/Game' const userMoveSymbol = '×' const initialGameBoard = [ ['', '', ''], ['', '', ''], ['', '', ''] ] let game beforeEach(() => { game = new Game() }) describe('Game', () => { // ... test('Writes user\'s symbol in top left cell', () => { const x = 0, y = 0 game.acceptUserMove(x, y) const board = game.getState() expect(board[x][y]).toEqual(userMoveSymbol) }) })
src/Game.js
export default class Game { constructor() { this._userMoveSymbol = '×' // ... } // ... acceptUserMove(x, y) { this._updateBoard(0, 0) } _updateBoard(x, y) { this._board[x][y] = this._userMoveSymbol } }

Обобщаем ход игрока 🔗

Сейчас метод acceptUserMove умеет обрабатывать только левую верхнюю клетку доски. Обобщим его и добавим обработку других клеток.

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

test/game.js
test('Writes user\'s symbol in cell with given coordinates', () => { const x = 1, y = 1 game.acceptUserMove(x, y) const board = game.getState() expect(board[x][y]).toEqual(userMoveSymbol) })

Расширяем метод 🔗

Чтобы метод acceptUserMove прошёл новый тест, нам нужно использовать не нулевые координаты, а переданные в аргументах. Изменение небольшое, но для нас в нём важно другое.

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

src/Game.js
- this._updateBoard(0, 0) + this._updateBoard(x, y)

Один тест вымещает другой 🔗

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

Это нормальная практика: тесты эволюционируют, а какие‑то из них постепенно отмирают.

test/game.js
- test('Writes user\'s symbol in top left cell', () => { - const x = 0, y = 0 - - game.acceptUserMove(x, y) - const board = game.getState() - - expect(board[x][y]).toEqual(userMoveSymbol) - }) test('Writes user\'s symbol in cell with given coordinates', () => { const x = 1, y = 1 game.acceptUserMove(x, y) const board = game.getState() expect(board[x][y]).toEequal(userMoveSymbol) })

Тестируем исключения 🔗

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

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

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

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

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

test/game.js
test('Throws an exception if user moves in taken cell', () => { const x = 2, y = 2 game.acceptUserMove(x, y) const func = game.acceptUserMove.bind(game, x, y) expect(func).toThrow('cell is already taken') })

Дорабатываем метод 🔗

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

Запускаем тесты, проверяем, что ничего не сломалось. Начинаем рефакторить.

src/Game.js
acceptUserMove(x, y) { + if (this._board[x][y]) { + throw new Error('cell is already taken') + return + } + this._updateBoard(x, y) }

Приводим в порядок реализацию проверки 🔗

Проверка на занятость клетки сейчас слишком прямолинейна и зависит от реализации доски. Вынесем проверку во внутренний метод _isCellFree.

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

После изменений снова проверяем, что тесты не падают.

src/Game.js
acceptUserMove(x, y) { - if (this._board[x][y]) { - throw new Error('cell is already taken') - return - } + if (!this._isCellFree(x, y)) { + return this._throwException('cell is already taken') + } this._updateBoard(x, y) } _updateBoard(x, y) { this._board[x][y] = this._userMoveSymbol } + _isCellFree(x, y) { + return !this._board[x][y] + } + + _throwException(msg) { + throw new Error(msg) + }

Ход компьютера 🔗

Теперь перейдём к ходу компьютера.

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

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

Напишем метод createComputerMove, который будет всегда ходить в левую верхнюю клетку доски.

test/game.js
test('Computer moves in top left cell', () => { game.createComputerMove() const board = game.getState() expect(board[0][0]).toEqual('o') })
src/Game.js
+ createComputerMove() { + + }

Создаём ход компьютера 🔗

Компьютер ходит ноликами. Ставим нолик в левую верхнюю клетку доски.

Проверяем, чтобы тесты проходили.

createComputerMove() { + this._board[0][0] = 'o' }

…и рефакторим 🔗

Метод для обновления доски _updateBoard у нас уже есть, но он работает только с крестиком. Доработаем его, добавив третьим параметром объект с настройками.

Нолик вынесем в константу _computerMoveSymbol.

И как обычно проверяем, что ничего не сломалось.

constructor() { this._userMoveSymbol = '×' + this._computerMoveSymbol = 'o' //... } // ... createComputerMove() { - this._board[0][0] = 'o' + this._updateBoard(0, 0, { + symbol: this._computerMoveSymbol + }) } - _updateBoard(x, y) { - this._board[x][y] = this._userMoveSymbol - } + _updateBoard(x, y, config={}) { + const {symbol=this._userMoveSymbol} = config + this._board[x][y] = symbol + }

Вдруг! 🔗

Допустим, в разработке изменились приоритеты. Теперь вам нужно написать ещё и журнал игры.

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

В нашем примере мы переключаемся на историю ходов. Начнём с метода получения истории getMoveHistory.

test/game.js
test('Game saves user\'s move in history', () => { const x = 1, y = 1 game.acceptUserMove(x, y) const history = game.getMoveHistory() expect(history).toEqual([{turn: 'user', x, y}]) })
src/Game.js
+ getMoveHistory() { + + }

Метод для получения истории 🔗

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

src/Game.js
getMoveHistory() { + return [{turn: 'user', x: 1, y: 1}] }

Рефакторим тесты и метод 🔗

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

Историю же вынесем в конструктор класса.

test/game.js
+ const userName = 'user' const userMoveSymbol = '×' // ... - expect(history).to.deep.equal([{turn: 'user', x, y}]) + expect(history).to.deep.equal([{turn: userName, x, y}])
src/Game.js
+ this._history = [{turn: 'user', x: 1, y: 1}] this._board = [ // ... getMoveHistory() { - return [{turn: 'user', x: 1, y: 1}] + return this._history }

Сохраняем ход компьютера в истории 🔗

Теперь будем сохранять ход компьютера в истории.

Тест не будет проходить, так как история всегда возвращает одинаковое значение.

test/game.js
test('Game saves computers\'s move in history', () => { game.createComputerMove() const history = game.getMoveHistory() expect(history).toEqual([{turn: 'computer', x: 0, y: 0}]) })

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

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

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

Теперь все тесты зелёные. Можно приступать к рефакторингу.

src/Game.js
- this._history = [{turn: 'user', x: 1, y: 1}] + this._history = [] //... + this._history.push({turn: 'user', x, y}) this._updateBoard(x, y) } // ... createComputerMove() { + this._history.push({turn: 'computer', x: 0, y: 0}) this._updateBoard(0, 0, {

В тестах вынесем имя компьютера в переменную.

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

Вынесем его в метод _updateHistory и заменим все вызовы на новый метод.

Имена пользователя и компьютера так же вынесем в константы.

Убеждаемся, что ничего не сломалось.

test/game.js
+ const computerName = 'computer' // ... - expect(history).toEqual([{turn: 'computer', x: 0, y: 0}]) + expect(history).toEqual([{turn: computerName, x: 0, y: 0}])
src/Game.js
constructor() { + this._userName = 'user' + this._computerName = 'computer' this._userMoveSymbol = '×' // ... - this._history.push({turn: 'user', x, y}) + this._updateHistory(this._userName, x, y) } createComputerMove() { - this._history.push({turn: 'computer', x: 0, y: 0}) + this._updateHistory(this._computerName, 0, 0) this._updateBoard(0, 0, { // ... + _updateHistory(turn, x, y) { + this._history.push({turn, x, y}) + }

Проверяем, как история записывает несколько ходов 🔗

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

Первая запись описывает ход пользователя, вторая — компьютера. Определить это мы можем через поле turn в объекте записи.

Запустив тесты, мы увидим, что новый тест тоже проходит. Но это ещё не значит, мы написали всё правильно.

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

test/game.js
test('Game saves 1 user\'s move and 1 computer\'s move in history', () => { const x = 1, y = 1 game.acceptUserMove(x, y) game.createComputerMove() const history = game.getMoveHistory() expect(history.length).toBe(2) expect(history[0].turn).toEqual(userName) expect(history[1].turn).toEqual(computerName) })

Возвращаемся к ходу компьютера 🔗

Допустим, текущая реализация истории нас устраивает. Можем вернуться к разработке хода компьютера.

Компьютер должен ходить в случайно выбранную клетку. Но как протестировать случайность?

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

Подберём такое число, чтобы компьютер ходил в центр доски.

Тест падает, так как компьютер ходит в левую верхнюю клетку.

test/game.js
test('Computer moves in randomly chosen cell', () => { const userMoveSymbol = '×' const computerMoveSymbol = 'o' // ... const mock = jest.spyOn(global.Math, 'random').mockReturnValue(0.5) game.createComputerMove() const board = game.getState() expect(board[1][1]).toEqual(computerMoveSymbol) mock.mockRestore() })

Так как наше поле имеет размер 3×3, случайная координата должна быть ≥0 и ≤2. Пишем получение случайной координаты с использованием Math.random.

Новый тест проходит, но посыпались два других.

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

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

Либо мы можем проверять только поле turn, чтобы оно соответствовало имени компьютера.

Воспользуемся первым вариантом.

src/Game.js
createComputerMove() { + const x = Math.floor(Math.random() * (3 - 0)) + const y = Math.floor(Math.random() * (3 - 0)) + - this._updateHistory(this._computerName, 0, 0) - this._updateBoard(0, 0, { + this._updateHistory(this._computerName, x, y) + this._updateBoard(x, y, { symbol: this._computerMoveSymbol }) }
test/game.js
- test('Computer moves in top left cell', () => { - game.createComputerMove() - const board = game.getState() - - expect(board[0][0]).toEqual(computerMoveSymbol) - }) // ... test('Game saves computer\'s move in history', () => { + const mock = jest.spyOn(global.Math, 'random').mockReturnValue(0.5) game.createComputerMove() const history = game.getMoveHistory() - expect(history).toEqual([{turn: computerName, x: 0, y: 0}]) + expect(history).toEqual([{turn: computerName, x: 1, y: 1}]) + mock.mockRestore() })

Рефакторинг начнём с выноса повторяющегося кода.

Создадим метод для получения случайной координаты _getRandomCoordinate. Размер поля вынесем в константу _fieldSize.

Запускаем тесты и проверяем.

src/Game.js
this._computerMoveSymbol = 'o' + this._fieldSize = 3 // ... createComputerMove() { - const x = Math.floor(Math.random() * (3 - 0)) - const y = Math.floor(Math.random() * (3 - 0)) + const x = this._getRandomCoordinate() + const y = this._getRandomCoordinate() + this._updateHistory(this._computerName, x, y) // ... + _getRandomCoordinate() { + return Math.floor(Math.random() * (this._fieldSize - 0)) + }

Заставляем компьютер пойти в последнюю клетку 🔗

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

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

test/game.js
test('Computer moves in cell that is not taken', () => { // fill all the cells with user's symbol except last for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (i !== 2 || j !== 2) game.acceptUserMove(i, j) } } game.createComputerMove() const board = game.getState() const userCount = board.reduce((result, row) => { return row.reduce((count, el) => { return el === userMoveSymbol ? ++count : count }, result) }, 0) const computerCount = board.reduce((result, row) => { return row.reduce((count, el) => { return el === computerMoveSymbol ? ++count : count }, result) }, 0) expect(userCount).toBe(8) expect(computerCount).toBe(1) expect(board[2][2]).toEqual(computerMoveSymbol) })

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

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

Чтобы цикл не стал бесконечным, проверим, есть ли свободные клетки вообще.

src/Game.js
createComputerMove() { - const x = this._getRandomCoordinate() - const y = this._getRandomCoordinate() + const freeCells = this._board.reduce((total, row) => + row.reduce((count, el) => + el === '' ? ++count : count, total), 0) + + if (!freeCells) return + + let x = this._getRandomCoordinate() + let y = this._getRandomCoordinate() + + while (!!this._board[x][y]) { + x = this._getRandomCoordinate() + y = this._getRandomCoordinate() + }

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

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

test/game.js
const fillCells = game => { for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { if (i !== 2 || j !== 2) game.acceptUserMove(i, j) } } } const count = (arr, symbol) => arr.reduce((result, row) => { return row.reduce((count, el) => { return el === symbol ? ++count : count }, result) }, 0) // ... test('Computer moves in cell that is not taken', () => { fillCells(game) game.createComputerMove() const board = game.getState() expect(count(board, userMoveSymbol)).toBe(8) expect(count(board, computerMoveSymbol)).toBe(1) expect(board[2][2]).toEqual(computerMoveSymbol) })

Подсчёт количества пустых клеток и получение случайных координат вынесем в методы _getFreeRandomCoordinates и _getFreeCellsCount. Они могут нам пригодиться дальше.

src/Game.js
createComputerMove() { if (this._getFreeCellsCount() === 0) return false const [x, y] = this._getFreeRandomCoordinates() this._updateHistory(this._computerName, x, y) this._updateBoard(x, y, { symbol: this._computerMoveSymbol }) } // ... _getFreeRandomCoordinates() { let x = this._getRandomCoordinate() let y = this._getRandomCoordinate() while (!!this._board[x][y]) { x = this._getRandomCoordinate() y = this._getRandomCoordinate() } return [x, y] } _getFreeCellsCount() { return this._board.reduce((total, row) => row.reduce((count, el) => el === '' ? ++count : count, total), 0) }

Заставляем компьютер говорить об ошибке 🔗

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

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

test/game.js
test('If there are no free cells computer throws an exception', () => { // fill all the cells for (let i = 0; i < 3; ++i) { for (let j = 0; j < 3; ++j) { game.acceptUserMove(i, j) } } const func = game.createComputerMove.bind(game) expect(func).toThrow('no cells available') })

Здесь пригодится метод _throwException, который мы определяли ранее.

Допишем условие, которое определяет отсутствие пустых клеток, добавив туда передачу ошибок.

src/Game.js
createComputerMove() { - if (this._getFreeCellsCount() === 0) return false + if (this._getFreeCellsCount() === 0) { + return this._throwException('no cells available') + }

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

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

В новом тесте заполняем все клетки, поэтому вызываем функцию без конфига.

После изменений проверяем, не покраснели ли тесты.

test/game.js
- const fillCells = game => { + const fillCells = (game, config={}) => { + const { x=-1, y=-1 } = config // ... for (let j = 0; j < 3; j++) { - if (i !== 2 || j !== 2) game.acceptUserMove(i, j) + if (i !== x || j !== y) game.acceptUserMove(i, j) } // ... test('Computer moves in cell that is not taken', () => { - fillCells(game) + fillCells(game, {x: 2, y: 2}) // ... test('If there are no free cells computer throws an exception', () => { - // fill all the cells - for (let i = 0; i < 3; ++i) { - for (let j = 0; j < 3; ++j) { - game.acceptUserMove(i, j) - } - } + fillCells(game)

Проверяем, победил ли пользователь 🔗

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

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

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

test/game.js
test('Checks if user won by horizontal', () => { game.acceptUserMove(0, 0) game.acceptUserMove(0, 1) game.acceptUserMove(0, 2) const userWon = game.isWinner(userName) expect(userWon).toEqual(true) })
src/Game.js
+ isWinner(player) { + return false + }

Аргументом передаём имя игрока, которого хотим проверить. В зависимости от имени будем выбирать искомый символ. Затем проверим, выполняется ли условие победы по горизонтали и вернём результат.

src/Game.js
isWinner(player) { const symbol = player === this._userName ? this._userMoveSymbol : this._computerMoveSymbol const win = [...Array(this._fieldSize).keys()].reduce((res, i) => { return this._board[i][0] === symbol && this._board[i][1] === symbol && this._board[i][2] === symbol || res }, false) return win }

Определение искомого символа по имени вынесем в метод _getSymbolForPlayer.

Массив заданной размерности будем создавать в константе range.

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

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

src/Game.js
isWinner(player) { const symbol = this._getSymbolForPlayer(player) const range = [...Array(this._fieldSize).keys()] const isEqual = this._checkCellEqual(symbol) const horizontal = range.reduce((res, i) => isEqual(i, 0) && isEqual(i, 1) && isEqual(i, 2) || res, false) return horizontal } _getSymbolForPlayer(player) { return player === this._userName ? this._userMoveSymbol : this._computerMoveSymbol } _checkCellEqual(symbol) { return (i, j) => this._board[i][j] === symbol }

DSL 🔗

В коде теста на первый взгляд всё хорошо. Мы объявляем, в какие клетки проставить крестик, чтобы потом проверить, победил ли пользователь.

Но попробуем посмотреть «взглядом новичка» на game.acceptUserMove(0, 0). Если ничего не знать о внутреннем устройстве класса, то сообразить, что делает эта строка, трудно.

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

В этом тесте мы проверяем, что если на доске строчка заполнена крестиками, то пользователь победил. Было бы круто прямо так и написать.

На самом деле мы можем так написать свой тест. Это называется DSL. Для этого нам потребуется что‑то, что будет переводить метод setBoardState в понятные методы для класса Game.

Для таких целей можно использовать паттерн строитель (builder). Класс GameBuilder возьмёт на себя логику создания игры с нужной нам доской.

Ключевая особенность классов‑строителей в том, что в каждом методе with- они возвращают this. Таким образом, при их использовании мы можем совмещать методы в цепочки, чтобы настраивать нужный нам объект.

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

Теперь писать тесты на проверку победы удобнее и нагляднее.

// pseudocode
somegame.setBoardState(` x x x . . . . . . `)
test/GameBuilder.js
import Game from '../src/Game' class GameBuilder { constructor() { this.game = new Game() } withBoardState(state) { state = state .split('\n') .filter(item => !!item.trim()) .map(item => item.trim().split(' ')) state.forEach((item, i) => { item.forEach((symbol, j) => { if (symbol === 'x') this.game.acceptUserMove(i, j) }) }) // will allow us to chain methods return this } build() { return this.game } } export default GameBuilder
test/game.js
import GameBuilder from '../src/GameBuilder' // ... test('Checks if user won by horizontal', () => { const game = new GameBuilder() .withBoardState(` x x x . . . . . .`) .build() const userWon = game.isWinner(userName) expect(userWon).to.equal(true) })

Переходим к DOM 🔗

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

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

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

Сейчас тест будет красным, так как таблица не создаётся. Приступаем к реализации.

test/dom.js
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals'; import jsdom from 'jsdom' import Game from '../src/Game' const {JSDOM} = jsdom const dom = new JSDOM('<html><body id="root"></body></html>') global.window = dom.window global.document = dom.window.document describe('DOM controller', () => { test('Creates empty table', () => { const domController = new DomController('#root') domController.createTable() expect(document.querySelectorAll('table').length).toBe(1) }) }) class DomController { constructor(root) {} createTable() {} }

Создаём пустую таблицу 🔗

В конструкторе привяжем класс к переданной ноде и сохраним её в rootNode. Всю работу ограничим внутри неё.

Внутри createTable создадим пустую таблицу и добавим её внутрь rootNode.

Проверим, что тест проходит, и начнём рефакторить.

test/dom.js
class DomController { constructor(root) { + this.rootNode = document.querySelector(root) } createTable() { + const child = document.createElement('table') + this.rootNode.appendChild(child) } }

Вынесем класс DomController в отдельный файл.

Добавим импорт в файл тестов и вынесем создание экземпляра DomController в функцию createInstance.

src/DomController.js
class DomController { constructor(root) { this.rootNode = document.querySelector(root) } createTable() { const child = document.createElement('table') this.rootNode.appendChild(child) } } export default DomController
test/dom.js
+ const createInstance = () => new DomController('#root') describe('DOM controller', () => { test('Creates empty table', () => { - const domController = new DomController('#root') + const domController = createInstance() domController.createTable() expect(document.querySelectorAll('table').length).toBe(1) }) })

Наполняем таблицу 🔗

Добавим в метод количество строк и столбцов, которое должно быть в таблице.

По задумке тест должен сказать, что у нас вместо трёх строк не найдено ни одной. Но он скажет, что у нас вместо одной таблицы — две.

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

Теперь тест падает с задуманной причиной, и можно переходить к реализации.

test/dom.js
afterEach(() => { document.body.innerHTML = '' }) // ... test('Creates table with 3 rows and 3 columns', () => { const domController = createInstance() domController.createTable(3, 3) expect(document.querySelectorAll('table').length).toBe(1) expect(document.querySelectorAll('tr').length).toBe(3) expect(document.querySelectorAll('td').length).toBe(9) })

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

Количество будем брать из аргументов. По умолчанию будем считать, что количество равно 0.

Тест проходит. Смотрим, нужно ли что‑то отрефакторить. Пока что всё выглядит хорошо, поэтому рефакторинг можем пропустить.

src/DomController.js
createTable(rows=0, cols=0) { const child = document.createElement('table') this.rootNode.appendChild(child) const table = this.rootNode.querySelector('table') for (let i = 0; i < rows; i++) { const row = table.insertRow(i) for (let j = 0; j < cols; j++) { const cell = row.insertCell(j) } } }

Обрабатываем клик 🔗

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

Как нам проверить, что пользователь кликнул на первую клетку, а контроллер правильно это обработал? Так как мы разрабатываем сперва тест, мы можем использовать особые поля специально для проверки.

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

test/dom.js
test('Remembers indices of last clicked cell', () => { const domController = createInstance() domController.createTable(3, 3) document.querySelector('table td').click() expect(domController.lastClickedIndices).toEqual([0, 0]) })
src/DomController.js
for (let j = 0; j < cols; j++) { const cell = row.insertCell(j) + cell.addEventListener('click', () => { + this.lastClickedIndices = [i, j] + }) }

Вынесем обработку клика в отдельный метод _handleCellClick.

Проверяем, что после рефакторинга тесты не сломались.

src/DomController.js
for (let j = 0; j < cols; j++) { const cell = row.insertCell(j) - cell.addEventListener('click', () => { - this.lastClickedIndices = [i, j] - }) + cell.addEventListener('click', this._handleCellClick.bind(this, i, j)) } } } + + _handleCellClick(row, col) { + this.lastClickedIndices = [row, col] + }

Привяжем клик к игре 🔗

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

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

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

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

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

test/dom.js
// ... const createInstance = (game={}) => { return new DomController({ game: game, root: '#root' }) } // ... test('Makes user move in game on cell click', () => { const gameMock = { acceptUserMove: jest.fn() } const domController = createInstance(gameMock) domController.createTable(3, 3) document.querySelector('table td').click() expect(domController.game.acceptUserMove).toHaveBeenCalled() })
src/DomController.js
class DomController { - constructor(root) { + constructor({root, game}) { + this.game = game this.rootNode = document.querySelector(root) this.lastClickedIndices = [-1, -1] } } // ... _handleCellClick(row, col) { this.lastClickedIndices = [row, col] + this.game.acceptUserMove(row, col) }

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

Вспоминаем, что при клике на занятую клетку у нас появится ошибка. Поэтому используем try-catch, чтобы ловить их.

src/DomController.js
_handleCellClick(row, col) { this.lastClickedIndices = [row, col] - this.game.acceptUserMove(row, col) + try { + this._makeUserMove(row, col) + } + catch(e) { + window.alert(e.message) + } + } + + _makeUserMove(row, col) { + this.game.acceptUserMove(row, col) + }

Проверяем ошибки 🔗

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

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

Проверить это можно с помощью шпиона (spy), который будет следить за вызовами alert. Если alert был вызван, то метод шпиона toHaveBeenCalled вернёт истину.

Так как мы использовали try-catch ошибка, полученная от acceptUserMove вызывает alert внутри _handleCellClick класса DomController. Тест автоматически пройден.

Также сразу позаботимся об очистке шпиона. В начале каждого теста он должен быть свежим, будто его ни разу не вызывали. Для этого будем сбрасывать его состояние после каждого теста в afterEach. После прохождения же всех тестов в afterAll восстановим метод window.alert, полностью очистив его от шпиона.

test/dom.js
const createGame = (board) => new Game(board) // ... beforeEach(() => { window.alert = jest.fn() }) afterEach(() => { // ... window.alert.mockReset() }) afterAll(() => { window.alert.mockRestore() }) // ... test('Gets an alert when user makes move in taken cell', () => { const game = createGame() const domController = createInstance(game) domController.init() document.querySelector('table td').click() document.querySelector('table td').click() expect(window.alert).toHaveBeenCalled() })

Перерисовываем таблицу 🔗

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

После обновления модели будем спрашивать у неё новое состояние и перерисовывать таблицу.

test/dom.js
test('Redraws table on cell click', () => { const game = createGame() const domController = createInstance(game) domController.init() document.querySelector('table td').click() const text = document.querySelector('table td').textContent expect(text).toEqual('×') })
scr/DomController.js
_makeUserMove(row, col) { this.game.acceptUserMove(row, col) + const board = this.game.getState() + const table = this.rootNode.querySelector('table') + + board.forEach((row, i) => { + row.forEach((col, j) => { + table + .querySelector(`tr:nth-child(${i+1}) td:nth-child(${j+1})`) + .innerHTML = col + }) + }) }

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

scr/DomController.js
_makeUserMove(row, col) { this.game.acceptUserMove(row, col) + this._redraw() + } + + _redraw() { const board = this.game.getState() const table = this.rootNode.querySelector('table')

Привязываем ход компьютера 🔗

Пусть компьютер ходит сразу после хода пользователя.

Даже если хочется, чтобы компьютер «подумал» перед ходом, нам следует двигаться небольшими шагами и разбивать задачи на подзадачи. Не забываем, что цикл разработки в TDD — 10–15 минут.

test/dom.js
test('Makes computer move right after users move', () => { const game = createGame() const domController = createInstance(game) domController.init() document.querySelector('table td').click() const text = document.querySelector('table').textContent expect(text.indexOf('o') > -1).toBe(true) })

Обновим метод _handleCellClick и добавим в него запуск хода компьютера.

Запускаем тесты, чтобы убедиться, что всё работает.

src/DomController.js
try { this._makeUserMove(row, col) + + this.game.createComputerMove() + this._redraw() }

Вынесем его так же в отдельный метод _makeComputerMove и будем вызывать его.

После рефакторинга снова запускаем тесты.

src/DomController.js
try { this._makeUserMove(row, col) - - this.game.createComputerMove() - this._redraw() + this._makeComputerMove() } + _makeComputerMove() { + this.game.createComputerMove() + this._redraw() + }

Покажем поздравление победителю 🔗

Если пользователь или компьютер побеждает в игре, покажем это.

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

Создадим игру, в которую передадим указанную доску. Для этого подправим класс Game так, чтобы конструктор принимал параметр board. Если он указан, то будет использоваться как начальное состояние.

test/dom.js
test('Creates status text below table if someone wins', () => { const game = createGame([ ['×', '×', ''], ['', '', ''], ['', '', ''] ]) const domController = createInstance(game) domController.init() document.querySelector('table tr:nth-child(1) td:nth-child(3)').click() const status = document.querySelector('#status') expect(status.textContent).toEqual('user won!') })
src/DomController.js
this._makeUserMove(row, col) + const state = this.game.checkGame() + if (state !== 'continue') { + const node = document.createElement('div') + const txt = document.createTextNode(state) + node.id = 'status' + node.appendChild(txt) + this.rootNode.appendChild(node) + } this._makeComputerMove()

Вынесем создание элемента в метод _createNode, а проверку состояния игры — в метод _checkContinue.

После хода пользователя или компьютера будем проверять, продолжается ли игра в методе _checkContinue. Если нет, то создаём поздравление победителю в методе _createNode и добавляем его в корневую ноду.

src/DomController.js
_handleCellClick(row, col) { this.lastClickedIndices = [row, col] try { this._makeUserMove(row, col) const continues = this._checkContinue() if (!continues) return this._makeComputerMove() this._checkContinue() } catch(e) { window.alert(e.message) } } // ... _checkContinue() { const state = this.game.checkGame() if (state !== 'continue') { const status = this._createNode('div', { text: state, id: 'status' }) this.rootNode.appendChild(status) return false } return true } _createNode(tag, config={}) { const {text, id} = config const node = document.createElement(tag) const txt = document.createTextNode(text) node.appendChild(txt) if (!!id) node.id = id return node }

Финальные штрихи 🔗

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

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

Перейдём к проверке игры в браузере. Создадим ХТМЛ‑страницу с подключением скрипта, из которого запустим игру.

Здесь пригодится сборка, которую мы настроили ранее. Запускаем в терминале npm run build. Вебпак соберёт игру в один бандл, который можно подключать к странице.

Результат

src/index.js
import DomController from './DomController.js' import Game from './Game.js' const game = new Game() const dom = new DomController({ root: 'body', game }) dom.init()

Заключение 🔗

TDD — отличный инструмент для разработки.

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

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

При разработке по TDD удобно разговаривать со специалистами из других областей с помощью DSL.

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

Что запомнить:

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

Ссылки по теме:

Результаты: