Cvičení: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14.
Nezapomeňte, že Čtení před cvičením je povinné a je z něj kvíz, který musíte vyplnit před cvičením.
Testování v shellu pomocí frameworku BATS
In this section we will briefly describe BATS – the testing system that we use for automated tests that are run on every push to GitLab.
Generally, automated tests are the only reasonable way to ensure your software is not slowly rotting and decaying. Good tests will capture regressions, ensure bugs are not reappearing and often serve as documentation of the expected behavior.
The motto write tests first may often seem exaggerated and difficult, but it contains a lot of truth (several reasons are listed for example in this article).
BATS is a system written in shell that targets shell scripts or any programs with CLI interface. If you are familiar with other testing frameworks (e.g. Python Nose), you will find BATS probably very similar and easy to use.
Generally, every test case is one shell function and BATS offers several helper functions to structure your tests.
Let us look at the example from BATS homepage:
#!/usr/bin/env bats
@test "addition using bc" {
result="$(echo 2+2 | bc)"
[ "$result" -eq 4 ]
}
The @test "addition using bc"
is a test definition. Internally, BATS
translates this into a function (indeed, you can imagine it as running
simple sed
script over the input and piping it to sh
) and the body is a
normal shell code.
BATS uses set -e
to terminate the code whenever any program terminates
with non-zero exit code. Hence, if [
terminates with non-zero, the test
fails.
Apart from this, there is nothing more about it in its basic form. Even with this basic knowledge, you can start using BATS to test your CLI programs.
Executing the tests is simple – make the file executable and run it. You
can choose from several outputs and with -f
you can filter which tests to
run. Look at bats --help
or here for
more details.
Commented example
Let’s write a test for our factor.py
program. We will use the version that
reads the number from argv
.
#!/usr/bin/env bats
@test "Factorize 7" {
run ./factor.py 7
[ "$output" = "7" ]
}
@test "Factorize 17" {
run ./factor.py 17
[ "$output" = "17" ]
}
We use a special BATS command run
to execute our program that also
captures its stdout into a variable named $output
.
And then we simply verify the correctness.
Let’s add another test case:
@test "Factorize 8" {
run ./factor.py 8
[ "$output" = "2 2 2" ]
}
This will fail, but the error message is not very helpful.
(in test file factor.bats, line 15)
`[ "$output" = "2 2 2" ]' failed
This is because BATS is a very thin framework that basically checks only the exit codes and not much more.
But we can improve that.
#!/usr/bin/env bats
check_it() {
run ./factor.py "$1"
[ "$output" = "$2" ]
}
@test "Factorize 7" {
check_it 7 7
}
@test "Factorize 17" {
check_it 17 17
}
@test "Factorize 8" {
check_it 8 "2 2 2"
}
The error message is not much better but the test is much more readable this way.
Let’s improve the check_it
function a bit more.
check_it() {
run ./factor.py "$1"
if [ "$output" = "$2" ]; then
return 0
fi
echo >&2
echo "-- Actual output --" >&2
echo "$output" >&2
echo "-- Expected output --" >&2
echo "$2" >&2
return 1
}
Spusťte test znovu:
(from function `check_it' in file factor.bats, line 13,
in test file factor.bats, line 25)
`check_it 8 "2 2 2"' failed
-- Actual output --
2
2
2
-- Expected output --
2 2 2
So basically our test was wrong all the time :-).
But this is actually usable for debugging our program.
We simply need to change our test a bit:
@test "Factorize 8" {
check_it 8 "2
2
2"
}
Yes, shell strings can span multiple lines just fine.
Adding more test cases is now a piece of cake. After this trivial update, our test suite will actually start making sense. And it will be useful to us.
Lepší testování (assertions)
BATS offers extensions for writing more readable tests.
Thus, instead of calling test
directly, we can use assert_equal
that
produces nicer message.
assert_equal "expected-value" "$actual"
Testy pro NSWI177
Our tests are packed with the assert extension plus several of our own. All
of them are part of the repository that is
downloaded
by run_tests.sh
in your repositories.
Feel free to execute the *.bats
file directly if you want to run just
certain test locally (i.e., not on GitLab).
grep
and sed
We have already mentioned these commands. The first one prints lines matching a given regular expression, the other one is able to change the lines according to the provided regular expression and its replacement.
Warning: both commands use a slightly different regex syntax.
Always check with the man page if you are not sure.
Generally, the biggest differences across tools/languages are in handling of
special characters for repetition or grouping (()
, {}
).
Cvičení
Find all lines in /etc/passwd
that contain the digit 9
.
Accounts with /sbin/nologin
in /etc/passwd
are generally system accounts
not used by a human user.
Print the list of these accounts.
Solution.
Find all lines in /etc/passwd
that start with any of the letters A, B, C or D
(case-insensitive).
Solution.
Find all lines which contain an even number of characters. Solution.
Find all e-mail addresses. Assume that a valid e-mail address has a format <s1>@<s2>.<s3>
, where each sequence <sN>
is a non-empty string of characters from English alphabet and sequences <s1>
and <s2>
may also contain digits or a dot .
.
Solution.
Print all lines containing a word (in English alphabet) which begins with capital letter and all other letters are lowercase.
Test that the word TeX
will not be matched.
Solution.
Remove all trailing spaces and tabulators. Solution.
Put every word (non-empty sequence of characters of the English alphabet) in parentheses. Solution.
Replace “Name Surname” by “Surname, N.”. Solution.
Delete all empty lines. Hint. Solution.
Reformat input to contain each sentence on a separate line.
Assume that each sentence begins with a capital English letter and ends with .
, !
, or ?
;
there may be any number of spaces between sentences.
Hint.
Solution.
Příklad většího skriptu
We will describe the following script in a bit more detail to explain typical idioms you can encounter. We will also build the script incrementally to give you an idea how to approach building bigger scripts.
But we provide complete script as well for you to check that you have build it from the fragments correctly.
Popis úlohy
Write a script that prints basic system information (hardware platform, kernel version, number of CPUs, and RAM size). The user should be able to choose different output formats.
Solution.Popis řešení
The core of our script is simple.
echo "Hardware platform: $( uname -m )"
echo "Kernel version: $( uname -r )"
echo "CPU count: $( nproc )"
echo "RAM size: $( sed -n 's#^MemTotal:[ ]*\([0-9]*\) kB#\1#p' </proc/meminfo )"
This output is useful for a human reader but not for machine processing. So let’s add a version that prints the output as assignment to shell variables that can be later used. I.e., in the following format.
PLATFORM="x86_64"
KERNEL_VERSION="5.10.16-arch1-1"
Of course, duplicating the script to contain the following is not a nice solution.
if [ "$format" = "shell" ]; then
echo "PLATFORM=$( uname -m )"
...
else
echo "Hardware platform: $( uname -m )"
...
fi
But it is possible to convert between these two formats. Let’s convert our script like this:
if [ "$format" = "shell" ]; then
column_no=1
else
column_no=2
fi
(
echo "PLATFORM:Hardware platform:$( uname -m )"
echo "KERNEL_VERSION:Kernel version:$( uname -r )"
echo "CPU_COUNT:CPU count:$( nproc )"
echo "RAM_TOTAL:RAM size:$( sed -n 's#^MemTotal:[ ]*\([0-9]*\) kB#\1#p' </proc/meminfo )"
) | cut '-d:' -f $column_no,3-
Not perfect but we are getting there. Let’s hide the conversion into a separate shell function.
format_normal() {
cut '-d:' -f 2,3
}
format_shell() {
cut '-d:' -f 1,3 | sed 's#:\(.*\)#="\1"#'
}
Then the script would contain the following pipeline:
(
...
echo "RAM_TOTAL:RAM size:$( sed -n 's#^MemTotal:[ ]*\([0-9]*\) kB#\1#p' </proc/meminfo )"
) | "format_${format}"
In a sense, we have used a polymorphism in our script as the $format
variable is technically a replacement of a virtual method table.
Adding JSON is a bit more complicated, but still doable. Note that we
down-case the variable names for nicer output. The final sed
is used to
replace the trailing comma (JSON is a very strict format).
format_json() {
local varname
local varvalue
echo "{"
cut '-d:' -f 1,3 | sed 's#:# #' | while read -r varname varvalue; do
echo -n "$varname" | tr 'A-Z' 'a-z' | sed 's#.*# "&": #'
echo "\"$varvalue\"",
done | sed '$s#,$##'
echo "}"
}
We can certainly use getopt
to allow the user to select the output format
but we will opt for using a configuration file or setting an environment
variable. Then, the default format can be specified in
"$HOME/.nswi177/sysinfo.rc"
or the script can be launched with:
FORMATTER=json ./sysinfo.sh
Many programs offer you all three options where the script first loads the
settings from a configuration file, optionally overrides them with a
environment variable, and getopt
can override these.
The loading in the script then looks like this (we switched to capitals to emphasize that the variable comes from the user and thus will be exported).
if [ -r "$HOME/.nswi177/sysinfo.rc" ]; then
. "$HOME/.nswi177/sysinfo.rc"
fi
if [ -z "${FORMATTER:-}" ]; then
FORMATTER="${DEFAULT_FORMATTER:-normal}"
fi
Hodnocené úlohy (deadline: 17. dubna)
DŮLEŽITÉ UPOZORNĚNÍ #1: úlohy níže záměrně zjednodušují některé předpoklady a cílí na dobře naformátovaný vstup. Pokud není chování definováno v textu, je definováno testy. Mnoho případů není záměrně definováno ani testováno – použijte selský rozum pro specifikaci chování v těchto případech.
DŮLEŽITÉ UPOZORNĚNÍ #2: nezapomeňte si vaší implementaci zkontrolovat nástrojem ShellCheck.
08/timeconv.sh
(20 bodů)
Napište skript, který převede čas ve formátu AM/PM do 24-hodinového.
Skript čte stdin a tiskne výsledky na stdout. Žádné argumenty nebudou předány a neočekává se, že by nějaké měly být rozpoznány.
Skript najde všechny výskyty hh:mmAM
nebo hh:mmPM
a nahradí je jejich
ekvivalentem v 24-hodinovém formátu.
Příklad vstupu/výstup může vypadat takto:
The event starts at 03:25PM and is expected to end at 06:17PM.
Registration will be opened from 09:00AM until 06:00 PM.
The event starts at 15:25 and is expected to end at 18:17.
Registration will be opened from 09:00 until 06:00 PM.
Očekáváme, že použijete samostatné výrazy pro jednotlivé odpolední hodiny,
protože převádět 03
na 15
, 04
na 16
atd. přímo v sed
u není úplně
přímočaré.
Ale můžete zkusit části skriptu vygenerovat. Nápověda:
echo "49 50 51 52 53 54" | sed -e "$( for i in 50 51 52; do echo "s:$i:$(( i - 50 )):g"; done )"
08/ip.sh
(20 bodů)
Stáhněte si zde výňatek ze záznamů o přístupech serveru Apache. V podstatě je to seznam souborů, o které byl webové server požádán (např. tak, že uživatel napsal jejich URL nebo klepl na odkaz). Tento soubor obsahuje jak úspěšné přístupy tak i záznamy, kde se požadavek nepodařilo vyřídit, protože soubor neexistoval (situace známá jako HTTP 404).
Některé položky jsou obyčejné překlepy, ale některé odhalují roboty, které se pokoušely proniknout do instalace WordPressu (která ovšem na serveru nikdy nebyla).
Každá řádka obsahuje IP adresu původce požadavku, datum, požadované URL (včetně metody), chybový kód, velikost odpovědi a identifikaci prohlížeče (user agent).
Váš skript bude takový soubor číst na stdinu a vypíše IP adresu stroje, který se nejvíce-krát pokoušel přistoupit na neexistující stránky (podívejte se po 404). Žádné argumenty nebudou předány a neočekává se, že by nějaké měly být rozpoznány.
Testy pracují na drobných částech původní souboru, abychom vám usnadnili ladění. Odkaz výše slouží jak ukázka skutečných dat.
Při skutečném nasazení byste použili speciální nástroje pro zpracování
podobných dat. Ty by nabídly výsledky ve strukturovanějším formátu.
Nicméně, grep
a sed
jsou perfektní nástroje pro hobby server nebo pokud
pracujete v nějak omezeném prostředí.
IP adresy jsme náhodně upravili tak, abychom zachovali anonymitu.
Mimochodem, pro úplný log je nejhorší (anonymizovanou) IP adresou
62.150.128.144
.
08/normalize.sh
(20 bodů)
Napište skript, který znormalizuje danou cestu.
Skript očekává jediný argument: cestu, kterou má normalizovat. Můžete předpokládat, že argument bude vždy uveden.
Skript provede normalizaci cesty následujícím způsobem:
- odkazy na aktuální adresář budou odstraněny, protože jsou nadbytečné
- odkazy na nadřazený (rodičovský) adresář budou odstraněny takovým způsobem, aby se nezměnil význam cesty (možná i opakovaně)
- skript nebude převádět relativní cestu na absolutní nebo naopak
- skript nebude kontrolovat, zda-li soubor skutečně existuje
Následující příklady ilustrují očekávané chování.
/etc/passwd
⇒/etc/passwd
a/b/././c/d
⇒a/b/c/d
/a/b/../c
⇒/a/c
/usr/../etc/
⇒/etc/
Můžete předpokládat, že jednotlivé komponenty cesty neobsahují znak nového
řádku nebo další speciální znaky jako :
, "
, :
nebo nějakou escape
sekvenci.
Nápověda: sed ':x; s/abb/ba/; tx'
zajistí, že s/abb/ba/
je voláno
opakovaně, dokud probíhá náhrada (:x
definuje návěští a tx
je podmíněný
skok na návěští pokud předchozí nahrazování změnilo vstup).
Vyzkoušejte s echo 'abbbb' | sed ...
.
08/markdown.sh
(40 bodů)
Napište jednoduchý převaděč z Markdownu do HTML.
Opět záměrně (a hodně) zjednodušujeme syntaxi: plnohodnotný parser by tady zafungoval lépe, ale účelem úlohy je vyzkoušet si práci se základními regulárními výrazy.
Převaděč musí podporovat následující styly:
Text se _zdůrazněním několika slov_.
se vytiskne jakoText se <em>zdůrazněním několika slov</em>.
Text se *silným zdůrazněním*.
se převede naText se <strong>silným zdůrazněním</strong>.
- Libovolný znak z množiny
>
,<
a&
musí být převeden na HTML entitu. - Odkazy ve formátu
[http://...|text odkazu]
budou převedeny na<a href="http://...">text odkazu</a>
.- URL bude vždy začínat na
http://
nebohttps://
- Znaky
<
,>
,&
a"
musí být escapovány uvnitř URL, tj. musí být převedeny na odpovídající HTML entity.
- URL bude vždy začínat na
Skript bude ignorovat další obvyklé vlastnosti Markdownu jako detekce odstavců nebo seznamů (ať už číslovaných nebo odrážkových).
Nepožadujeme a nebudeme testovat funkci při vnoření výše zmíněných značek.
Takže není potřeba ošetřovat situaci jako nějaké _zdůraznění *uvnitř* dalšího_ nebo _speciální > znaky_ atd.
Můžete také předpokládat, že formátovací znaky nejdou přes více řádků. Může jich být více na jednom řádku, ale nebudou se překrývat.
Skript čte stdin a tiskne výsledky na stdout. Žádné argumenty nebudou předány a neočekává se, že by nějaké měly být rozpoznány.
Učební výstupy
Znalosti konceptů
Znalost konceptů znamená, že rozumíte významu a kontextu daného tématu a jste schopni témata zasadit do většího rámce. Takže, jste schopni …
-
vysvětlit, co je to regulární výraz
-
vysvětlit, proč se lintery a style checkery mají používat na kontrolu zdrojových kódů
Praktické dovednosti
Praktické dovednosti se obvykle týkají použití daných programů pro vyřešení různých úloh. Takže, dokážete …
-
vytvářet a používat jednoduché regulární výrazy na filtrování textu
grep
em -
používat
sed
na nahrazování v textu -
používat
.
asource
-
používat a interpretovat výsledky Shellchecku
-
používat a interpretovat výsledky Pylintu
-
spouště testy založené na BATS
-
číst testy BATS
-
vytvářet jednoduché testy BATS (volitelné)