Написание сценариев оболочки очень похоже на программирование. Некоторые сценарии требуют небольших временных затрат; в то время как другие сложные сценарии могут потребовать обдумывания, планирования и большей приверженности. С этой точки зрения имеет смысл использовать подход, основанный на тестировании, и модульное тестирование наших сценариев оболочки.
Чтобы получить максимальную отдачу от этого учебного пособия, вы должны быть знакомы с интерфейсом командной строки (CLI); Вы можете обратиться к руководству «Командная строка — ваш лучший друг», если вам требуется переподготовка. Вам также необходимо базовое понимание сценариев Bash-подобной оболочки. Наконец, вы можете захотеть ознакомиться с концепциями разработки через тестирование (TDD) и модульного тестирования в целом; Обязательно ознакомьтесь с этими учебными руководствами по PHP, чтобы понять основную идею.
Подготовьте среду программирования
Во-первых, вам нужен текстовый редактор для написания сценариев оболочки и модульных тестов. Используйте свой любимый!
Для запуска наших модульных тестов мы будем использовать среду модульного тестирования оболочки shUnit2 . Он был разработан для Bash-подобных оболочек и работает с ними. shUnit2 — это платформа с открытым исходным кодом, выпущенная по лицензии GPL, и копия этой инфраструктуры также включена в пример исходного кода этого руководства.
Установка shUnit2 очень проста; Просто скачайте и распакуйте архив в любое место на вашем жестком диске. Он написан на Bash, и поэтому фреймворк состоит только из файлов сценариев. Если вы планируете часто использовать shUnit2, я настоятельно рекомендую поместить его в определенное место в вашей переменной PATH.
Написание нашего первого теста
Для этого руководства извлеките shUnit в каталог с тем же именем в папке Sources
(см. Код, прилагаемый к этому руководству). Создайте папку « Tests
» внутри Sources
и firstTest.sh
новый файл с firstTest.sh
.
01
02
03
04
05
06
07
08
09
10
|
#!
### firstTest.sh ###
function testWeCanWriteTests () {
assertEquals «it works» «it works»
}
## Call and Run all Tests
.
|
Чем сделать ваш тестовый файл исполняемым.
1
2
|
$ cd __your_code_folder__/Tests
$ chmod +x firstTest.sh
|
Теперь вы можете просто запустить его и посмотреть результат:
1
2
3
4
5
6
|
$ ./firstTest.sh
testWeCanWriteTests
Ran 1 test.
OK
|
В нем говорится, что мы провели один успешный тест. Теперь давайте провалим тест; измените оператор assertEquals
чтобы две строки не assertEquals
и повторите тест:
1
2
3
4
5
6
7
|
$ ./firstTest.sh
testWeCanWriteTests
ASSERT:expected:<it works> but was:<it does not work>
Ran 1 test.
FAILED (failures=1)
|
Игра в теннис
Вы пишете приемочные тесты в начале проекта / функции / истории, когда вы можете четко определить конкретное требование.
Теперь, когда у нас есть рабочая среда тестирования, давайте напишем скрипт, который читает файл, принимает решения на основе содержимого файла и выводит информацию на экран.
Основная цель сценария — показать счет игры в теннис между двумя игроками. Мы сконцентрируемся только на сохранении счета одной игры; все остальное зависит от вас. Правила подсчета очков:
- В начале каждый игрок имеет нулевой счет, который называется «любовь»
- Первый, второй и третий выигранные мячи отмечены как «пятнадцать», «тридцать» и «сорок».
- Если при «сорока» счет равен, это называется «двойка».
- После этого счет сохраняется как «Преимущество» для игрока, который набирает на одно очко больше, чем другой игрок.
- Игрок становится победителем, если ему удается иметь преимущество не менее двух очков и выигрывает не менее трех очков (то есть, если он набрал не менее сорока).
Определение ввода и вывода
Наше приложение будет читать счет из файла. Другая система поместит информацию в этот файл. Первая строка этого файла данных будет содержать имена игроков. Когда игрок набирает очко, его имя пишется в конце файла. Типичный файл результатов выглядит следующим образом:
1
2
3
4
5
6
7
8
9
|
John — Michael
John
John
Michael
John
Michael
Michael
John
John
|
Вы можете найти это содержимое в файле input.txt
в папке Source
.
Вывод нашей программы записывает счет на экран по одной строке за раз. Выход должен быть:
1
2
3
4
5
6
7
8
9
|
John — Michael
John: 15 — Michael: 0
John: 30 — Michael: 0
John: 30 — Michael: 15
John: 40 — Michael: 15
John: 40 — Michael: 30
Deuce
John: Advantage
John: Winner
|
Этот вывод также можно найти в файле output.txt
. Мы будем использовать эту информацию для проверки правильности нашей программы.
Приемочный тест
Вы пишете приемочные тесты в начале проекта / функции / истории, когда вы можете четко определить конкретное требование. В нашем случае этот тест просто вызывает наш сценарий, который скоро будет создан, с именем входного файла в качестве параметра, и ожидает, что выходные данные будут идентичны рукописному файлу из предыдущего раздела:
01
02
03
04
05
06
07
08
09
10
11
12
13
|
#!
### acceptanceTest.sh ###
function testItCanProvideAllTheScores () {
cd ..
./tennisGame.sh ./input.txt > ./results.txt
diff ./output.txt ./results.txt
assertTrue ‘Expected output differs.’
}
## Call and Run all Tests
.
|
Мы будем запускать наши тесты в папке Source/Tests
; поэтому cd ..
переносит нас в каталог Source
. Затем он пытается запустить tennisGamse.sh
, который еще не существует. Затем команда diff
сравнивает два файла: ./output.txt
— это наш рукописный вывод, а ./results.txt
будет содержать результат нашего сценария. Наконец, assertTrue
проверяет значение выхода diff
.
Но сейчас наш тест возвращает следующую ошибку:
1
2
3
4
5
6
7
8
9
|
$ ./acceptanceTest.sh
testItCanProvideAllTheScores
./acceptanceTest.sh: line 7: tennisGame.sh: command not found
diff: ./results.txt: No such file or directory
ASSERT:Expected output differs.
Ran 1 test.
FAILED (failures=1)
|
Давайте превратим эти ошибки в приятную ошибку, создав пустой файл под названием tennisGame.sh
и сделав его исполняемым. Теперь, когда мы запускаем наш тест, мы не получаем ошибку:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
./acceptanceTest.sh
testItCanProvideAllTheScores
1,9d0
< John — Michael
< John: 15 — Michael: 0
< John: 30 — Michael: 0
< John: 30 — Michael: 15
< John: 40 — Michael: 15
< John: 40 — Michael: 30
< Deuce
< John: Advantage
< John: Winner
ASSERT:Expected output differs.
Ran 1 test.
FAILED (failures=1)
|
Реализация с TDD
Создайте еще один файл с именем unitTests.sh
для наших модульных тестов. Мы не хотим запускать наш скрипт для каждого теста; мы только хотим запустить функции, которые мы тестируем. Итак, мы заставим tennisGame.sh
запускать только те функции, которые будут находиться в functions.sh
:
01
02
03
04
05
06
07
08
09
10
11
12
|
#!
### unitTest.sh ###
source ../functions.sh
function testItCanProvideFirstPlayersName () {
assertEquals ‘John’ `getFirstPlayerFrom ‘John — Michael’`
}
## Call and Run all Tests
.
|
Наш первый тест прост. Мы пытаемся получить имя первого игрока, когда строка содержит два имени, разделенных дефисом. Этот тест не пройден, потому что у нас еще нет функции getFirstPlayerFrom
:
1
2
3
4
5
6
7
8
9
|
$ ./unitTest.sh
testItCanProvideFirstPlayersName
./unitTest.sh: line 8: getFirstPlayerFrom: command not found
shunit2:ERROR assertEquals() requires two or three arguments;
shunit2:ERROR 1: John 2: 3:
Ran 1 test.
OK
|
Реализация getFirstPlayerFrom
очень проста. Это регулярное выражение, которое вводится командой sed
:
1
2
3
4
5
|
### functions.sh ###
function getFirstPlayerFrom () {
echo $1 |
}
|
Сейчас тест проходит:
1
2
3
4
5
6
|
$ ./unitTest.sh
testItCanProvideFirstPlayersName
Ran 1 test.
OK
|
Давайте напишем еще один тест для имени второго игрока:
1
2
3
4
5
6
7
|
### unitTest.sh ###
[…]
function testItCanProvideSecondPlayersName () {
assertEquals ‘Michael’ `getSecondPlayerFrom ‘John — Michael’`
}
|
Провал:
1
2
3
4
5
6
7
8
|
./unitTest.sh
testItCanProvideFirstPlayersName
testItCanProvideSecondPlayersName
ASSERT:expected:<Michael> but was:<John>
Ran 2 tests.
FAILED (failures=1)
|
А теперь реализация функции, чтобы она прошла:
1
2
3
4
5
6
7
|
### functions.sh ###
[…]
function getSecondPlayerFrom () {
echo $1 |
}
|
Сейчас у нас проходят тесты:
1
2
3
4
5
6
7
|
$ ./unitTest.sh
testItCanProvideFirstPlayersName
testItCanProvideSecondPlayersName
Ran 2 tests.
OK
|
Давайте ускорим дело
Начиная с этого момента, мы напишем тест и реализацию, и я объясню только то, что заслуживает упоминания.
Давайте проверим, есть ли у нас игрок с одним счетом. Добавлен следующий тест:
1
2
3
4
|
function testItCanGetScoreForAPlayerWithOnlyOneWin () {
standings=$’John — Michael\nJohn’
assertEquals ‘1’ `getScoreFor ‘John’ «$standings»`
}
|
И решение:
1
2
3
4
5
6
|
function getScoreFor () {
player=$1
standings=$2
totalMatches=$(echo «$standings» | grep $player | wc -l)
echo $(($totalMatches-1))
}
|
Мы используем некоторые необычные кавычки для передачи последовательности новой строки ( \n
) внутри строкового параметра. Затем мы используем grep
чтобы найти строки, содержащие имя игрока, и сосчитать их с помощью wc
. Наконец, мы вычитаем одну из результата, чтобы противодействовать наличию первой строки (она содержит только данные, не связанные с оценкой)
Сейчас мы находимся на этапе рефакторинга TDD.
Я только что понял, что код на самом деле работает более чем на одно очко на игрока, и мы можем реорганизовать наши тесты, чтобы отразить это Измените вышеуказанную функцию тестирования на следующее:
1
2
3
4
|
function testItCanGetScoreForAPlayer () {
standings=$’John — Michael\nJohn\nMichael\nJohn’
assertEquals ‘2’ `getScoreFor ‘John’ «$standings»`
}
|
Тесты еще проходят. Время двигаться дальше с нашей логикой:
1
2
3
|
function testItCanOutputScoreAsInTennisForFirstPoint () {
assertEquals ‘John: 15 — Michael: 0’ «`displayScore ‘John’ 1 ‘Michael’ 0`»
}
|
И реализация:
1
2
3
4
5
6
7
|
function displayScore () {
if [ «$2» -eq ‘1’ ];
playerOneScore=’15’
fi
echo «$1: $playerOneScore — $3: $4»
}
|
Я проверяю только второй параметр. Похоже, я обманываю, но это самый простой код для прохождения теста. Написание еще одного теста заставляет нас добавить больше логики, но какой тест мы должны написать дальше?
Мы можем пойти двумя путями. Проверка, получает ли второй игрок очко, вынуждает нас написать еще одно утверждение if
, но мы должны добавить else
оператор if, если решим проверить второе очко первого игрока. Последнее подразумевает более простую реализацию, поэтому давайте попробуем это:
1
2
3
|
function testItCanOutputScoreAsInTennisForSecondPointFirstPlayer () {
assertEquals ‘John: 30 — Michael: 0’ «`displayScore ‘John’ 2 ‘Michael’ 0`»
}
|
И реализация:
1
2
3
4
5
6
7
8
9
|
function displayScore () {
if [ «$2» -eq ‘1’ ];
playerOneScore=’15’
else
playerOneScore=’30’
fi
echo «$1: $playerOneScore — $3: $4»
}
|
Это все еще выглядит обманом, но работает отлично. Продолжая третий пункт:
1
2
3
|
function testItCanOutputScoreAsInTennisForTHIRDPointFirstPlayer () {
assertEquals ‘John: 40 — Michael: 0’ «`displayScore ‘John’ 3 ‘Michael’ 0`»
}
|
Реализация:
01
02
03
04
05
06
07
08
09
10
11
|
function displayScore () {
if [ «$2» -eq ‘1’ ];
playerOneScore=’15’
elif [ «$2» -eq ‘2’ ];
playerOneScore=’30’
else
playerOneScore=’40’
fi
echo «$1: $playerOneScore — $3: $4»
}
|
Это if-elif-else
начинает раздражать меня. Я хочу изменить это, но давайте сначала проведем рефакторинг наших тестов. У нас есть три очень похожих теста; Итак, давайте запишем их в одном тесте, который делает три утверждения:
1
2
3
4
5
|
function testItCanOutputScoreWhenFirstPlayerWinsFirst3Points () {
assertEquals ‘John: 15 — Michael: 0’ «`displayScore ‘John’ 1 ‘Michael’ 0`»
assertEquals ‘John: 30 — Michael: 0’ «`displayScore ‘John’ 2 ‘Michael’ 0`»
assertEquals ‘John: 40 — Michael: 0’ «`displayScore ‘John’ 3 ‘Michael’ 0`»
}
|
Это лучше, и это все еще проходит. Теперь давайте создадим аналогичный тест для второго игрока:
1
2
3
4
5
|
function testItCanOutputScoreWhenSecondPlayerWinsFirst3Points () {
assertEquals ‘John: 0 — Michael: 15’ «`displayScore ‘John’ 0 ‘Michael’ 1`»
assertEquals ‘John: 0 — Michael: 30’ «`displayScore ‘John’ 0 ‘Michael’ 2`»
assertEquals ‘John: 0 — Michael: 40’ «`displayScore ‘John’ 0 ‘Michael’ 3`»
}
|
Запуск этого теста приводит к интересному выводу:
1
2
3
4
|
testItCanOutputScoreWhenSecondPlayerWinsFirst3Points
ASSERT:expected:<John: 0 — Michael: 15> but was:<John: 40 — Michael: 1>
ASSERT:expected:<John: 0 — Michael: 30> but was:<John: 40 — Michael: 2>
ASSERT:expected:<John: 0 — Michael: 40> but was:<John: 40 — Michael: 3>
|
Ну, это было неожиданно. Мы знали, что у Майкла будут неправильные результаты. Сюрприз это Джон; он должен иметь 0, а не 40. Давайте исправим это, сначала изменив выражение if-elif-else
:
01
02
03
04
05
06
07
08
09
10
11
12
13
|
function displayScore () {
if [ «$2» -eq ‘1’ ];
playerOneScore=’15’
elif [ «$2» -eq ‘2’ ];
playerOneScore=’30’
elif [ «$2» -eq ‘3’ ];
playerOneScore=’40’
else
playerOneScore=$2
fi
echo «$1: $playerOneScore — $3: $4»
}
|
if-elif-else
теперь более сложный, но мы по крайней мере зафиксировали оценки Джона:
1
2
3
4
|
testItCanOutputScoreWhenSecondPlayerWinsFirst3Points
ASSERT:expected:<John: 0 — Michael: 15> but was:<John: 0 — Michael: 1>
ASSERT:expected:<John: 0 — Michael: 30> but was:<John: 0 — Michael: 2>
ASSERT:expected:<John: 0 — Michael: 40> but was:<John: 0 — Michael: 3>
|
Теперь давайте исправим Майкла:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
function displayScore () {
echo «$1: `convertToTennisScore $2` — $3: `convertToTennisScore $4`»
}
function convertToTennisScore () {
if [ «$1» -eq ‘1’ ];
playerOneScore=’15’
elif [ «$1» -eq ‘2’ ];
playerOneScore=’30’
elif [ «$1» -eq ‘3’ ];
playerOneScore=’40’
else
playerOneScore=$1
fi
echo $playerOneScore;
}
|
Это сработало хорошо! Теперь пришло время наконец-то изменить этот уродливый выражение if-elif-else
:
1
2
3
4
|
function convertToTennisScore () {
declare -a scoreMap=(‘0′ ’15’ ’30’ ’40’)
echo ${scoreMap[$1]};
}
|
Карты стоимости замечательны! Давайте перейдем к делу «Двойка»:
1
2
3
|
function testItSayDeuceWhenPlayersAreEqualAndHaveEnoughPoinst () {
assertEquals ‘Deuce’ «`displayScore ‘John’ 3 ‘Michael’ 3`»
}
|
Мы проверяем на «Двойку», когда все игроки имеют по крайней мере 40 баллов.
1
2
3
4
5
6
7
|
function displayScore () {
if [ $2 -gt 2 ] && [ $4 -gt 2 ] && [ $2 -eq $4 ];
echo «Deuce»
else
echo «$1: `convertToTennisScore $2` — $3: `convertToTennisScore $4`»
fi
}
|
Теперь мы проверяем преимущество первого игрока:
1
2
3
|
function testItCanOutputAdvantageForFirstPlayer () {
assertEquals ‘John: Advantage’ «`displayScore ‘John’ 4 ‘Michael’ 3`»
}
|
И чтобы это прошло:
1
2
3
4
5
6
7
8
9
|
function displayScore () {
if [ $2 -gt 2 ] && [ $4 -gt 2 ] && [ $2 -eq $4 ];
echo «Deuce»
elif [ $2 -gt 2 ] && [ $4 -gt 2 ] && [ $2 -gt $4 ];
echo «$1: Advantage»
else
echo «$1: `convertToTennisScore $2` — $3: `convertToTennisScore $4`»
fi
}
|
Опять уродливый if-elif-else
, и у нас тоже много дублирования. Все наши тесты пройдены, так что давайте сделаем рефакторинг:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
function displayScore () {
if outOfRegularScore $2 $4 ;
checkEquality $2 $4
checkFirstPlayerAdv $1 $2 $4
else
echo «$1: `convertToTennisScore $2` — $3: `convertToTennisScore $4`»
fi
}
function outOfRegularScore () {
[ $1 -gt 2 ] && [ $2 -gt 2 ]
return $?
}
function checkEquality () {
if [ $1 -eq $2 ];
echo «Deuce»
fi
}
function checkFirstPlayerAdv () {
if [ $2 -gt $3 ];
echo «$1: Advantage»
fi
}
|
Это будет работать на данный момент. Давайте проверим преимущество для второго игрока:
1
2
3
|
function testItCanOutputAdvantageForSecondPlayer () {
assertEquals ‘Michael: Advantage’ «`displayScore ‘John’ 3 ‘Michael’ 4`»
}
|
И код:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
|
function displayScore () {
if outOfRegularScore $2 $4 ;
checkEquality $2 $4
checkAdvantage $1 $2 $3 $4
else
echo «$1: `convertToTennisScore $2` — $3: `convertToTennisScore $4`»
fi
}
function checkAdvantage () {
if [ $2 -gt $4 ];
echo «$1: Advantage»
elif [ $4 -gt $2 ];
echo «$3: Advantage»
fi
}
|
Это работает, но у нас есть некоторое дублирование в функции checkAdvantage
. Давайте упростим это и назовем это дважды:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
|
function displayScore () {
if outOfRegularScore $2 $4 ;
checkEquality $2 $4
checkAdvantage $1 $2 $4
checkAdvantage $3 $4 $2
else
echo «$1: `convertToTennisScore $2` — $3: `convertToTennisScore $4`»
fi
}
function checkAdvantage () {
if [ $2 -gt $3 ];
echo «$1: Advantage»
fi
}
|
Это на самом деле лучше, чем наше предыдущее решение, и оно возвращается к первоначальной реализации этого метода. Но теперь у нас есть другая проблема: мне неудобно с переменными $1
, $2
, $3
и $4
. Им нужны осмысленные имена:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
|
function displayScore () {
firstPlayerName=$1;
secondPlayerName=$3;
if outOfRegularScore $firstPlayerScore $secondPlayerScore;
checkEquality $firstPlayerScore $secondPlayerScore
checkAdvantageFor $firstPlayerName $firstPlayerScore $secondPlayerScore
checkAdvantageFor $secondPlayerName $secondPlayerScore $firstPlayerScore
else
echo «$1: `convertToTennisScore $2` — $3: `convertToTennisScore $4`»
fi
}
function checkAdvantageFor () {
if [ $2 -gt $3 ];
echo «$1: Advantage»
fi
}
|
Это делает наш код длиннее, но он значительно более выразителен. Мне это нравится.
Пришло время найти победителя:
1
2
3
|
function testItCanOutputWinnerForFirstPlayer () {
assertEquals ‘John: Winner’ «`displayScore ‘John’ 5 ‘Michael’ 3`»
}
|
Нам нужно только изменить функцию checkAdvantageFor
:
1
2
3
4
5
6
7
8
9
|
function checkAdvantageFor () {
if [ $2 -gt $3 ];
if [ `expr $2 — $3` -gt 1 ];
echo «$1: Winner»
else
echo «$1: Advantage»
fi
fi
}
|
Мы почти закончили! В качестве последнего шага мы напишем код в tennisGame.sh
чтобы пройти приемочный тест. Это будет довольно простой код:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
|
#!
### tennisGame.sh ###
.
playersLine=`head -n 1 $1`
echo «$playersLine»
firstPlayer=`getFirstPlayerFrom «$playersLine»`
secondPlayer=`getSecondPlayerFrom «$playersLine»`
wholeScoreFileContent=`cat $1`
totalNoOfLines=`echo «$wholeScoreFileContent» |
for currentLine in `seq 2 $totalNoOfLines`
do
firstPlayerScore=$(getScoreFor $firstPlayer «`echo \»$wholeScoreFileContent\» | head -n $currentLine`»)
secondPlayerScore=$(getScoreFor $secondPlayer «`echo \»$wholeScoreFileContent\» | head -n $currentLine`»)
displayScore $firstPlayer $firstPlayerScore $secondPlayer $secondPlayerScore
done
|
Мы читаем первую строку, чтобы получить имена двух игроков, и затем мы постепенно читаем файл, чтобы вычислить счет.
Последние мысли
Сценарии оболочки могут легко вырасти из нескольких строк кода в несколько сотен строк. Когда это происходит, обслуживание становится все труднее. Использование TDD и модульное тестирование может значительно облегчить поддержку вашего сложного сценария, не говоря уже о том, что оно заставляет вас создавать свои сложные сценарии более профессионально.