Alati za programere9 min čitanja
Testovi za ono što se ne izvršava: CI, infrastruktura i metapodaci prodavnica
Workflow-i, docker-compose, tekstovi o privatnosti i metapodaci App Store-a ne izvršavaju se u testu, a svaki može da obori izdanje. Evo kako ih ID Caller čuva check skriptama i testovima koji namerno kvare fajlove.
Neki od najvažnijih fajlova aplikacije ID Caller nikad se ne pokreću u testu. GitHub Actions workflow, docker-compose fajl, Prometheus pravilo za alert, politika privatnosti na sajtu, opis u App Store-u: ništa od toga se ne kompajlira i ništa ne obara unit test kad je pogrešno. Lome se kasnije, na deploy-u, tokom incidenta ili na App Review-u.
ID Caller je naša aplikacija za privatne pozive bez broja telefona: klijent na Kotlin/Compose Multiplatform-u i bekend na Go-u. Konfiguraciju i tekstove oko nje tretiramo kao kod. Svaka takva površina ima skriptu check-*.sh koja proverava njene invarijante, a svaka skripta ima skup testova koji kopira fajlove u fiksturu, pokvari jednu stvar i zahteva da provera padne.
Inventar
Repozitorijum ima osam check skripti, po jednu za svaku površinu, plus check-all.sh, koji ih lokalno pokreće redom zajedno sa Go i KMP proverama. Pored njih, u scripts/tests/ nalazi se deset skupova testova: osam za check skripte, jedan za skripte za bekap, vraćanje i rotaciju tajne i jedan za generator koji pravi iOS fajl Localizable.xcstrings iz Compose resursa. Kad smo ih pokrenuli za ovaj članak, bilo je ukupno 300 prolaznih slučajeva.
Pravilo je zapisano u uputstvima projekta: nova neizvršna površina znači novu check skriptu po istom obrascu, skup testova sa fiksturom i pomoćnom funkcijom damage i korak u check-all.sh.
Anatomija check skripte
Svih osam skripti ima isti šablon. Evo check-store.sh, skraćenog na kostur, ograničenja dužine i jedno App Review pravilo:
#!/usr/bin/env bash
# Upotreba: check-store.sh [ROOT] (podrazumevano koren repozitorijuma)
set -euo pipefail
ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}"
fail=0
err() {
echo "FAIL: $*" >&2
fail=1
}
require_grep() { # file pattern message [grep-flags]
local file="$1" pattern="$2" message="$3" flags="${4:--q}"
[[ -f "$ROOT/$file" ]] || return 0 # nedostajući fajl prijavljuje require_file
grep $flags "$pattern" "$ROOT/$file" || err "$file: $message"
}
IOS_META=docs/store/metadata
AND_META=docs/store/metadata/android
# ... require_file za svaki fajl metapodataka
# 2. Ograničenja dužine polja u prodavnicama (u karakterima, preko python3)
if command -v python3 >/dev/null 2>&1; then
check_len() { # file max
local file="$1" max="$2" n
[[ -f "$ROOT/$file" ]] || return 0
n="$(python3 -c 'import sys; print(len(open(sys.argv[1], encoding="utf-8").read().strip()))' "$ROOT/$file")"
[[ "$n" -le "$max" ]] || err "$file: $n karaktera (ograničenje $max)"
}
for locale in ru en-US; do
check_len "$IOS_META/$locale/name.txt" 30
check_len "$IOS_META/$locale/keywords.txt" 100
# ...
done
# ... isto za ostale lokale i Google Play
fi
# 3b. App Review 2.3.10: bez pominjanja drugih platformi i njihovih prodavnica
# u metapodacima App Store-a (odbijanje 07.08.2026). Direktorijum android/
# su metapodaci za Google Play, zabrana se na njega ne odnosi.
ios_only_texts=()
while IFS= read -r f; do
ios_only_texts+=("$f")
done < <(find "$ROOT/$IOS_META" -type f -name '*.txt' -not -path "$ROOT/$AND_META/*" 2>/dev/null)
if [[ "${#ios_only_texts[@]}" -gt 0 ]]; then
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/forbidden-platforms.sh"
for pattern in "${FORBIDDEN_PLATFORMS[@]}"; do
hits="$(grep -lE "$pattern" "${ios_only_texts[@]}" 2>/dev/null || true)"
if [[ -n "$hits" ]]; then
err "pominjanje druge platforme (App Review 2.3.10), obrazac '$pattern': $hits"
fi
done
fi
# 6. Review Notes: obrazloženje za VoIP, seed ID, video demo
rn=docs/store/review-notes.md
require_grep "$rn" 'voip' "nema obrazloženja za VoIP" -qi
require_grep "$rn" 'reportNewIncomingCall' "nema reportNewIncomingCall (invarijanta 5 u CLAUDE.md)"
# ...
if [[ "$fail" -ne 0 ]]; then
# ...
exit 1
fiŠablon počiva na četiri odluke:
- Koren je argument. Podrazumevano skripta proverava repozitorijum, ali može da se usmeri na bilo koji direktorijum, i upravo ta jedna linija čini proveru testabilnom.
errbeleži grešku i nastavlja dalje, pa jedno pokretanje ispisuje sve pokvarene invarijante umesto da stane na prvoj.require_grepćuti kad fajla nema, jer je to već prijaviorequire_file. Jedna greška, jedna poruka.- Opcioni alati se isključuju bez buke. YAML validacija radi samo ako postoji
python3sa PyYAML-om, a CI poslovi koji se na nju oslanjaju eksplicitno instaliraju PyYAML.
Liste obrazaca koje koristi više provera nalaze se u scripts/lib/. Zabranjene formulacije o „apsolutnoj anonimnosti” učitavaju i provera sajta i provera prodavnica, pa obrazac ispravljen jednom važi za obe površine.
Šta čuva osam provera
check-ci.sh: workflow fajlovi postoje i parsiraju se; serverski pipeline i dalje pokrećegolangci-lint,go testigovulncheck; pipeline aplikacije i dalje gradi iOS na macOS runner-u. Hvata „privremeno” obrisano skeniranje ranjivosti i pokvarenrenovate.json.check-infra.sh: Terraform, compose, TURN, alerting, bekapi. Odbija*.enc.yamlbez SOPS markera (tajna u čistom tekstu), vraćeni deploy korak prekossh(deploy radimo samo preko Dokploy API-ja) i/metricsotvoren spolja.check-e2e.sh: svih sedam Maestro tokova postoji i pokreće aplikaciju, orkestrator je izvršan i poziva svaki tok, a noćni workflow pada kad staging nije dostupan.check-privacy.sh: na sajtu nema apsolutnih obećanja anonimnosti; politika privatnosti na oba jezika navodi tačno ono što server čuva (ID, javni ključ, push token, veze između ID-jeva), sa rokom čuvanja od 12 meseci i napomenom da P2P poziv otkriva vaš IP sagovorniku; iOS privacy manifest ne prijavljuje praćenje i zaista je uključen u target aplikacije.check-store.sh: fajlovi metapodataka postoje i nisu prazni, polja staju u ograničenja prodavnica, Review Notes objašnjavaju korišćenje VoIP-a, a tekstovi za App Store ne pominju druge platforme.check-release.sh: potpisivanje i kredencijali dolaze samo iz properties fajlova i promenljivih okruženja, a fastlane, uklonjen 19. avgusta, ne vraća se da pregazi metapodatke koje sada vodimo direktno u konzolama prodavnica.check-deeplinks.sh: universal link za povezivanje usklađen je između bekenda, Android manifesta, iOS entitlement-a, zajedničke navigacije i association fajlova sajta. Rupa u bilo kom sloju znači mrtav link.check-bootstrap.sh: invarijante kostura projekta, poput fiksiranih verzija Go-a i Jave.
Deo pravila čine ožiljci. App Store je 7. avgusta odbio build 1.0 (2) po smernici 2.3.10 zbog kartice na uvodnom ekranu sa natpisom „Android & iOS”. Sada check-store.sh zabranjuje imena platformi u metapodacima App Store-a, a Kotlin test radi isto za UI stringove. Alert je 20. avgusta podigao lažni critical na osam poziva, jer su se neodgovoreni pozivi računali kao kvarovi. Sada provera zahteva da pravilo oduzima neodgovorene pozive, da ima prag minimalnog obima i da koristi or vector(0): bez toga bi uvođenje pravila pre bekenda tiho utišalo alert. Dana 23. septembra našli smo Grafana panele koji su prikazivali podatke sa staginga, pa sada svaki upit na dashboard-u mora da gleda produkciju.
Testiranje provera: fikstura plus damage
Provera koja nikad ne pada gora je od nikakve: daje zelenu kvačicu ni za šta. Zato svaka skripta ima skup testova, i osim početnog kostura, testovi su uvek nastajali pre skripte: commit-ovi sa porukom „failing tests …” stižu sa potpuno crvenim skupom, jer skripta još ne postoji.
assert_ok() { # name cmd...
local name="$1"; shift
if "$@" >/dev/null 2>&1; then
echo "ok - $name"; pass=$((pass + 1))
else
echo "FAIL - $name (očekivan exit 0)"; failures=$((failures + 1))
fi
}
# ... assert_fail je zrcalna slika; STORE_FILES je spisak fajlova prodavnica
# Fikstura: minimalna kopija fajlova prodavnica u privremenom direktorijumu
make_fixture() {
local dir f
dir="$(mktemp -d)"
for f in "${STORE_FILES[@]}"; do
[[ -f "$REPO_ROOT/$f" ]] && { mkdir -p "$dir/$(dirname "$f")"; cp "$REPO_ROOT/$f" "$dir/$f"; }
done
echo "$dir"
}
damage() { # file pattern: briše linije sa pattern (ništa ne radi ako fajla nema)
local file="$1" pattern="$2"
[[ -f "$file" ]] || return 0
grep -v -- "$pattern" "$file" > "$file.tmp" && mv "$file.tmp" "$file"
}
cleanup_dirs=()
trap 'rm -rf "${cleanup_dirs[@]:-}"' EXIT
assert_ok "pravi repozitorijum prolazi proveru" "$CHECK" "$REPO_ROOT"
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
assert_ok "validna fikstura prolazi proveru" "$CHECK" "$fx"
for f in "${STORE_FILES[@]}"; do
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
rm -f "$fx/$f"
assert_fail "nedostajući $f se hvata" "$CHECK" "$fx"
done
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
printf '\nWorks on Android too.\n' >> "$fx/$IOS_META/en-US/description.txt"
assert_fail "\"Android\" u en opisu (App Store) se hvata" "$CHECK" "$fx"
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
printf '\nЗвоните между Android и iOS.\n' \
>> "$fx/$AND_META/ru-RU/full_description.txt"
assert_ok "\"Android\" u metapodacima za Google Play je dozvoljen" "$CHECK" "$fx"
fx="$(make_fixture)"; cleanup_dirs+=("$fx")
damage "$fx/docs/store/review-notes.md" 'reportNewIncomingCall'
assert_fail "review-notes bez reportNewIncomingCall se hvata" "$CHECK" "$fx"
# ...Svi skupovi testova imaju isti oblik:
- Dva osnovna slučaja: pravi repozitorijum prolazi i sveža fikstura prolazi. Drugi dokazuje da je fikstura validna polazna tačka, pa kasniji pad dolazi od oštećenja, a ne od fajla koji smo zaboravili da kopiramo.
- Jedno oštećenje po fiksturi. Svaki slučaj kvari tačno jednu invarijantu u svežoj kopiji i očekuje izlazni kod različit od nule: obrisane linije, dopisana zabranjena fraza, pokvaren JSON ili YAML, obrisan fajl,
chmod -xna skripti. - Eksplicitni dozvoljeni slučajevi. „Android” u metapodacima za Google Play mora da prođe; placeholder otisci u
assetlinks.jsondaju samo upozorenje. Bez takvih slučajeva preterano revnosan obrazac pada na legitimnom tekstu, a ljudi nauče da ignorišu proveru.
U suštini, ovo je ručno pisano mutaciono testiranje konfiguracije: svako oštećenje je mutant, a provera mora da ga ubije.
Kad je damage test razotkrio slab obrazac
Provera bekapa je nekad tražila age -r u backup.sh da bi potvrdila da se dump-ovi šifruju. Na code review-u smo primetili da i sama poruka o grešci skripte za nedostajućeg primaoca sadrži age -r, pa bi brisanje pravog pipe-a za šifrovanje ostavilo proveru zelenom. Rešenje je bio precizniji obrazac, | age -r, i damage slučaj koji briše samo liniju sa pipe-om, a poruku ostavlja. Grep provera je dobra onoliko koliko je dobar njen obrazac, a skup testova je mesto gde se obrasci izoštravaju.
Jedan runner i CI korak koji čuva sam sebe
Runner ima jedanaest linija:
#!/usr/bin/env bash
# scripts/tests/run-all.sh: jedinstvena ulazna tačka za shell testove check skripti.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
for test_script in scripts/tests/*.test.sh; do
echo "==> $test_script"
"$test_script"
donePokreće se u contract workflow-u na svakom pull request-u:
dto-sync:
runs-on: ubuntu-latest
steps:
# ... checkout, instalacija Go-a, golden provera
- name: CI config invariants
run: ./scripts/check-ci.sh
- name: Privacy texts invariants (T-041, R-08)
run: ./scripts/check-privacy.sh
- name: Shell validator test suites
run: ./scripts/tests/run-all.shPošto svaki skup testova počinje slučajem „pravi repozitorijum prolazi”, ovaj korak usput pokreće svih osam provera nad stvarnim stablom. Workflow-i za deploy i E2E dodatno pozivaju check-infra.sh i check-e2e.sh direktno.
Runner je stigao u junu, u ispravci nakon code review-a; pre toga su skupovi testova postojali, ali ih CI nije pokretao. Da korak ne bi ponovo nestao, zahteva ga check-ci.sh, a njegov skup testova briše tu liniju iz kopije workflow-a u fiksturi i očekuje pad:
require_grep .github/workflows/contract.yml 'scripts/tests/run-all\.sh' \
"nema pokretanja shell testova check skripti"Isti review je ispravio i tiši problem. Noćni E2E i nedeljni test opterećenja ranije su se tiho preskakali kad staging nije bio dostupan, pa je pokretanje moglo da izgleda zeleno bez ijednog izvršenog toka. Sada padaju, a provere zahtevaju zaštitu sa exit 1 u oba.
Gde su granice pristupa
Ove provere dokazuju prisustvo, ne smisao: require_grep 'govulncheck' prolazi čak i ako reč preživi samo u komentaru. To prihvatamo. Skripte su zamke za regresije koje ljudi zaista prave (obrisan korak, vraćeno podešavanje, zabranjena fraza), a pravo izvršavanje ostaje na terraform validate, OpenAPI validatoru i noćnom Maestro pokretanju. Promena odluke znači promenu provere i njenog testa u istom commit-u, i to trenje je namerno.
Kako da počnete u svom projektu
- Izaberite jednu površinu koja vas je već zabolela: workflow, compose fajl, tekstove za prodavnice.
- Zapišite njene invarijante običnim rečenicama, pa svaku pretvorite u
require_fileilirequire_grep. - Neka skripta prima koreni direktorijum kao argument i skuplja sve greške pre izlaska.
- Prvo napišite testove: fiksturu koja kopira samo ono što provera čita, osnovne slučajeve za pravi repozitorijum i svežu fiksturu, pa jedno oštećenje po invarijanti.
- Dodajte dozvoljene slučajeve svuda gde obrazac može da okine na legitimnom tekstu.
- Zajedničke liste obrazaca držite u jednom fajlu koji se učitava sa
source. - Pokrećite sve skupove testova kroz jedan runner u CI-ju i neka jedna provera zahteva da taj korak postoji.
- Kad vas incident ili odbijanje nauče novom pravilu, dodajte invarijantu i njen damage test u istoj izmeni.
ONLYAPPS Team
Novi Sad, Srbija · onlyapps.org