Разработка через тестирование: улучшаем навыки. Часть 2
Сделаем простую реализацию стека в JavaScript с помощью разработки через тестирование.
Стек — структура данных, организованная по принципу LIFO: Last In, First Out. В стеке есть три основные операции:
push: добавление элемента
pop: удаление элемента
peek: добавление головного элемента
Создадим класс и назовем Stack. Чтобы усложнить задачу, предположим, что стек имеет фиксированную вместимость. Вот свойства и функции реализации нашего стека:
items: элементы стека. Мы будем использовать массив для реализации стека.
capacity: вместимость стека.
isEmpty(): возвращает true, если стек пуст, иначе false.
isFull(): возвращает true, если стек достигает максимальной емкости, т.е. когда вы не можете добавить другой элемент. В противном случае возвращает false.
push(element): добавляет элемент. Возвращает Full, если стек переполнен.
pop: удаляет элемент. Возвращает Empty, если стек пуст.
peek(): добавляет головной элемент.
Мы собираемся создать два файла stack.js и stack.spec.js. Я использовал расширение .spec.js, потому что привык к нему, но вы можете использовать .test.js или дать ему другое имя и переместить в __tests__.
Поскольку мы практикуемся в разработке через тестирование, напишем провальный тест.
Сначала проверим конструктор. Чтобы протестировать файл, вам необходимо импортировать файл стека:
<code><span class="hljs-keyword">const</span> Stack = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./stack'</span>)</code>Для тех, кому интересно, почему я не использовал здесь import — последняя стабильная версия Node.js не поддерживает эту функцию на сегодняшний день. Я мог бы добавить Babel, но не хочу перегружать вас. Когда вы тестируете класс или функцию, запустите тест и опишите, какой файл или класс вы тестируете. Здесь речь идет о стеке:
<code>describe(<span class="hljs-string">'Stack'</span>, () => { })</code>Затем нам нужно проверить, что при инициализации стека создается пустой массив, и мы устанавливаем правильную вместимость. Итак, пишем следующий тест:
<code>it(<span class="hljs-string">'Should constructs the stack with a given capacity'</span>, () => { <span class="hljs-keyword">let</span> stack = <span class="hljs-keyword">new</span> Stack(<span class="hljs-number">3</span>) expect(stack.items).toEqual([]) expect(stack.capacity).toBe(<span class="hljs-number">3</span>) })</code>Заметьте, что мы используем toEqual и не используем toBe для stack.items, потому что они не ссылаются на один и тот же массив.Теперь запустим yarn test stack.spec.js. Мы запускаем Jest в определенном файле, потому что мы не хотим, чтобы другие тесты были испорчены. Вот результат:
<code><span class="hljs-class"><span class="hljs-keyword"><span class="hljs-class"><span class="hljs-keyword">class</span></span></span> <span class="hljs-title"><span class="hljs-class"><span class="hljs-title">Stack</span></span></span> </span>{ <span class="hljs-keyword">constructor</span>() { } } <span class="hljs-built_in">module</span>.exports = Stack</code>Запустите тест снова:
<code><span class="hljs-keyword">constructor</span>() { <span class="hljs-keyword">this</span>.items = [] }</code>Если вы снова запустите тест, вы получите такую же ошибку для capacity, поэтому вам также нужно будет установить вместимость:
<code><span class="hljs-keyword">constructor</span>(capacity) { <span class="hljs-keyword">this</span>.items = [] <span class="hljs-keyword">this</span>.capacity = capacity }</code>Запустим тест:
isEmpty
Чтобы проверить isEmpty, мы собираемся инициализировать пустой стек, протестировать, если isEmpty возвращает true, добавить элемент и снова проверить его.<code>it(<span class="hljs-string">'Should have an isEmpty function that returns true if the stack is empty and false otherwise'</span>, () => { <span class="hljs-keyword">let</span> stack = <span class="hljs-keyword">new</span> Stack(<span class="hljs-number">3</span>) expect(stack.isEmpty()).toBe(<span class="hljs-literal">true</span>) stack.items.push(<span class="hljs-number">2</span>) expect(stack.isEmpty()).toBe(<span class="hljs-literal">false</span>) })</code>Когда вы запустите тест, вы должны получить следующую ошибку:
<code><span class="hljs-built_in">TypeError</span>: stack.isEmpty is not a <span class="hljs-function"><span class="hljs-keyword">function</span></span></code>Чтобы решить эту проблему, нам надо создать isEmpty внутри класса Stack:
<code>isEmpty () { }</code>Если вы запустите тест, вы должны получить еще одну ошибку:
<code>Expected: <span class="hljs-literal">true</span> Received: <span class="hljs-literal">undefined</span></code>В isEmpty ничего не добавляется. Stack пуст, если в нем нет элементов:
<code>isEmpty () { <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.items.length === <span class="hljs-number">0</span> }</code>
isFull
Это то же самое, что и isEmpty, так как это упражнение проверяет эту функцию с помощью TDD. Вы найдете решение в самом конце статьи.
Push
Здесь нам надо протестировать три вещи:- новый элемент должен быть добавлен в начало стека;
- push возвращает Full, если стек заполнен;
- элемент, который был недавно добавлен, должен быть возвращен.
<code>describe(<span class="hljs-string">'Stack.push'</span>, () => { })</code>
Добавление элемента
Создадим новый стек и добавим элемент. Последним элементом массива items должен быть только что добавленный элемент.<code>describe(<span class="hljs-string">'Stack.push'</span>, () => { it(<span class="hljs-string">'Should add a new element on top of the stack'</span>, () => { <span class="hljs-keyword">let</span> stack = <span class="hljs-keyword">new</span> Stack(<span class="hljs-number">3</span>) stack.push(<span class="hljs-number">2</span>) expect(stack.items[stack.items.length - <span class="hljs-number">1</span>]).toBe(<span class="hljs-number">2</span>) }) })</code>Если вы запустите тест, то увидите, что push не определен и это нормально. push потребуется параметр, чтобы что-то добавить в стек:
<code>push (element) { <span class="hljs-keyword">this</span>.items.push(element) }</code>Тест снова пройден. Вы что-то заметили? Мы сохраняем копию этой строки:
<code><span class="hljs-keyword">let</span> stack = <span class="hljs-keyword">new</span> Stack(<span class="hljs-number">3</span>)</code>Это очень раздражает. К счастью, у нас есть метод beforeEach, который позволяет выполнять некоторую настройку перед каждым тестовым прогоном. Почему бы не воспользоваться этим?
<code><span class="hljs-keyword">let</span> stack beforeEach(<span class="hljs-function"><span class="hljs-params"><span class="hljs-function"><span class="hljs-params">()</span></span></span> =></span> { stack = <span class="hljs-keyword">new</span> Stack(<span class="hljs-number">3</span>) })</code>
Важно:
стек должен быть объявлен раньше beforeEach. В самом деле, если вы определяете его в методе beforeEach, переменная стека не будет определена во всех тестах, потому что она не находится в правильной области.Важнее важного:
мы также должны создать метод afterEach. Экземпляр стека теперь будет использоваться для всех тестов. Это может вызвать некоторые трудности. Сразу после beforeEach добавьте этот метод:<code>afterEach(<span class="hljs-function"><span class="hljs-params"><span class="hljs-function"><span class="hljs-params">()</span></span></span> =></span> { stack.items = [] })</code>Теперь вы можете удалить инициализацию стека во всех тестах. Вот полный тестовый файл:
<code><span class="hljs-keyword">const</span> Stack = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./stack'</span>) describe(<span class="hljs-string">'Stack'</span>, () => { <span class="hljs-keyword">let</span> stack beforeEach(<span class="hljs-function"><span class="hljs-params"><span class="hljs-function"><span class="hljs-params">()</span></span></span> =></span> { stack = <span class="hljs-keyword">new</span> Stack(<span class="hljs-number">3</span>) }) afterEach(<span class="hljs-function"><span class="hljs-params"><span class="hljs-function"><span class="hljs-params">()</span></span></span> =></span> { stack.items = [] }) it(<span class="hljs-string">'Should constructs the stack with a given capacity'</span>, () => { expect(stack.items).toEqual([]) expect(stack.capacity).toBe(<span class="hljs-number">3</span>) }) it(<span class="hljs-string">'Should have an isEmpty function that returns true if the stack is empty and false otherwise'</span>, () => { stack.items.push(<span class="hljs-number">2</span>) expect(stack.isEmpty()).toBe(<span class="hljs-literal">false</span>) }) describe(<span class="hljs-string">'Stack.push'</span>, () => { it(<span class="hljs-string">'Should add a new element on top of the stack'</span>, () => { stack.push(<span class="hljs-number">2</span>) expect(stack.items[stack.items.length - <span class="hljs-number">1</span>]).toBe(<span class="hljs-number">2</span>) }) }) })</code>
Тестирование возвращаемого значения
Есть тест:
<code>it(<span class="hljs-string">'Should return the new element pushed at the top of the stack'</span>, () => { <span class="hljs-keyword">let</span> elementPushed = stack.push(<span class="hljs-number">2</span>) expect(elementPushed).toBe(<span class="hljs-number">2</span>) })</code>Когда вы запустите тест, получите:
<code>Expected: <span class="hljs-number">2</span> Received: <span class="hljs-literal">undefined</span></code>Ничего не возвращается внутри push! Мы должны исправить это:
<code>push (element) { <span class="hljs-keyword">this</span>.items.push(element) <span class="hljs-keyword">return</span> element }</code>
Возвращение full, если стек переполнен
В этом тесте нам нужно сначала заполнить стек, добавить элемент, убедиться, что ничего лишнего не было добавлено в стек и что возвращаемое значение равно Full.
<code>it(<span class="hljs-string">'Should return full if one tries to push at the top of the stack while it is full'</span>, () => { stack.items = [<span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>] <span class="hljs-keyword">let</span> element = stack.push(<span class="hljs-number">4</span>) expect(stack.items[stack.items.length - <span class="hljs-number">1</span>]).toBe(<span class="hljs-number">3</span>) expect(element).toBe(<span class="hljs-string">'Full'</span>) })</code>Вы увидите эту ошибку, когда запустите тест:
<code>Expected: <span class="hljs-number">3</span> Received: <span class="hljs-number">4</span></code>Итак, элемент добавлен. Это то, что мы и хотели. Сначала нужно проверить, заполнен ли стек, прежде чем добавить что-то:
<code>push (element) { <span class="hljs-keyword">if</span> (<span class="hljs-keyword">this</span>.isFull()) { <span class="hljs-keyword">return</span> <span class="hljs-string">'Full'</span> } <span class="hljs-keyword">this</span>.items.push(element) <span class="hljs-keyword">return</span> element }</code>Тест пройден.
Упражнение: pop и peek
Самое время попрактиковаться. Протестируйте и реализуйте pop и peek.
Подсказки:
- pop очень похожа на push
- peek также похожа на push
- До сих пор мы не рефакторили код, потому что в этом не было необходимости. В этих функциях может быть способ рефакторить ваш код после написания тестов и их прохождения. Не беспокойтесь, изменяя код, тесты для того и нужны.
Решение
Ну как упражнение? Справились? Если нет, не расстраивайтесь. Тестирование требует времени и усилий.<code><span class="hljs-class"><span class="hljs-keyword"><span class="hljs-class"><span class="hljs-keyword">class</span></span></span> <span class="hljs-title"><span class="hljs-class"><span class="hljs-title">Stack</span></span></span> </span>{ <span class="hljs-keyword">constructor</span> (capacity) { <span class="hljs-keyword">this</span>.items = [] <span class="hljs-keyword">this</span>.capacity = capacity } isEmpty () { <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.items.length === <span class="hljs-number">0</span> } isFull () { <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.items.length === <span class="hljs-keyword">this</span>.capacity } push (element) { <span class="hljs-keyword">if</span> (<span class="hljs-keyword">this</span>.isFull()) { <span class="hljs-keyword">return</span> <span class="hljs-string">'Full'</span> } <span class="hljs-keyword">this</span>.items.push(element) <span class="hljs-keyword">return</span> element } pop () { <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.isEmpty() ? <span class="hljs-string">'Empty'</span> : <span class="hljs-keyword">this</span>.items.pop() } peek () { <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.isEmpty() ? <span class="hljs-string">'Empty'</span> : <span class="hljs-keyword">this</span>.items[<span class="hljs-keyword">this</span>.items.length - <span class="hljs-number">1</span>] } } <span class="hljs-built_in">module</span>.exports = Stack <span class="hljs-keyword">const</span> Stack = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./stack'</span>) describe(<span class="hljs-string">'Stack'</span>, () => { <span class="hljs-keyword">let</span> stack beforeEach(<span class="hljs-function"><span class="hljs-params"><span class="hljs-function"><span class="hljs-params">()</span></span></span> =></span> { stack = <span class="hljs-keyword">new</span> Stack(<span class="hljs-number">3</span>) }) afterEach(<span class="hljs-function"><span class="hljs-params"><span class="hljs-function"><span class="hljs-params">()</span></span></span> =></span> { stack.items = [] }) it(<span class="hljs-string">'Should construct the stack with a given capacity'</span>, () => { expect(stack.items).toEqual([]) expect(stack.capacity).toBe(<span class="hljs-number">3</span>) }) it(<span class="hljs-string">'Should have an isEmpty function that returns true if the stack is empty and false otherwise'</span>, () => { expect(stack.isEmpty()).toBe(<span class="hljs-literal">true</span>) stack.items.push(<span class="hljs-number">2</span>) expect(stack.isEmpty()).toBe(<span class="hljs-literal">false</span>) }) it(<span class="hljs-string">'Should have an isFull function that returns true if the stack is full and false otherwise'</span>, () => { expect(stack.isFull()).toBe(<span class="hljs-literal">false</span>) stack.items = [<span class="hljs-number">4</span>, <span class="hljs-number">5</span>, <span class="hljs-number">6</span>] expect(stack.isFull()).toBe(<span class="hljs-literal">true</span>) }) describe(<span class="hljs-string">'Push'</span>, () => { it(<span class="hljs-string">'Should add a new element on top of the stack'</span>, () => { stack.push(<span class="hljs-number">2</span>) expect(stack.items[stack.items.length - <span class="hljs-number">1</span>]).toBe(<span class="hljs-number">2</span>) }) it(<span class="hljs-string">'Should return the new element pushed at the top of the stack'</span>, () => { <span class="hljs-keyword">let</span> elementPushed = stack.push(<span class="hljs-number">2</span>) expect(elementPushed).toBe(<span class="hljs-number">2</span>) }) it(<span class="hljs-string">'Should return full if one tries to push at the top of the stack while it is full'</span>, () => { stack.items = [<span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>] <span class="hljs-keyword">let</span> element = stack.push(<span class="hljs-number">4</span>) expect(stack.items[stack.items.length - <span class="hljs-number">1</span>]).toBe(<span class="hljs-number">3</span>) expect(element).toBe(<span class="hljs-string">'Full'</span>) }) }) describe(<span class="hljs-string">'Pop'</span>, () => { it(<span class="hljs-string">'Should removes the last element at the top of a stack'</span>, () => { stack.items = [<span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>] stack.pop() expect(stack.items).toEqual([<span class="hljs-number">1</span>, <span class="hljs-number">2</span>]) }) it(<span class="hljs-string">'Should returns the element that have been just removed'</span>, () => { stack.items = [<span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>] <span class="hljs-keyword">let</span> element = stack.pop() expect(element).toBe(<span class="hljs-number">3</span>) }) it(<span class="hljs-string">'Should return Empty if one tries to pop an empty stack'</span>, () => { <em>// By default, the stack is empty</em> expect(stack.pop()).toBe(<span class="hljs-string">'Empty'</span>) }) }) describe(<span class="hljs-string">'Peek'</span>, () => { it(<span class="hljs-string">'Should returns the element at the top of the stack'</span>, () => { stack.items = [<span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>] <span class="hljs-keyword">let</span> element = stack.peek() expect(element).toBe(<span class="hljs-number">3</span>) }) it(<span class="hljs-string">'Should return Empty if one tries to peek an empty stack'</span>, () => { <em>// By default, the stack is empty</em> expect(stack.peek()).toBe(<span class="hljs-string">'Empty'</span>) }) }) })</code>Если вы посмотрите на файлы, вы увидите, что я использовал тройное условие в pop и peek. Это то, что я рефакторил. Старая реализация выглядела так:
<code><span class="hljs-keyword">if</span> (<span class="hljs-keyword">this</span>.isEmpty()) { <span class="hljs-keyword">return</span> <span class="hljs-string">'Empty'</span> } <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>.items.pop()</code>
Разработка через тестирование позволяет нам рефакторить код после того, как тесты были написаны, я нашел более короткую реализацию, не беспокоясь о поведении своих тестов.
Запустим тест еще раз:
Тесты улучшают качество кода. Надеюсь, вы теперь понимаете всю пользу тестирования и будете чаще применять TDD.
Перевод статьи How to get better at testing with test-driven development от Digital Skynet :)