flowchart LR
A([codewijziging]) --> B([build])
B --> C([code analysis])
C --> D([automatische tests])
D --> E([packaging])
E --> F([release naar staging])
Les 5: TDD en CI/CD
Inleiding
Python Code Testen
Voor we het over Test Driven Development (TDD) kunnen hebben moeten we kort testen van Python code in het algemeen bekijken
Soorten Testen
Er zijn heel wat verschillende manieren om testen te categoriseren maar in het kader van deze lessen is het nuttig om een aantal basis-categorieën te kennen.
Unit tests
Een unit test test één klein onderdeel van de code, bijvoorbeeld één functie of één methode. Unit tests zijn in principe snel, eenvoudig en zeer geschikt voor TDD.
Integration tests
Een integration test controleert of meerdere onderdelen goed samenwerken.
Bijvoorbeeld:
- werkt een class goed samen met een andere class?
- wordt data correct gelezen uit een bestand?
- werkt onze code correct samen met een database of API?
Deze tests zijn vaak iets trager en complexer dan unit tests.
End-to-end tests
Een end-to-end test test een volledige gebruikersflow van begin tot einde.
Bijvoorbeeld:
Een gebruiker vult een formulier in, klikt op verzenden, en ziet daarna het verwachte resultaat.
Deze tests gebruiken vaak extra tools of frameworks.
Testing Hello World
Gereduceerd tot zijn eenvoudigste vorm is een (unit) test niets meer dan een functie die de te testen code aanroept en het resultaat controleert.
hello.py
def greet(name: str) -> str:
return f"Hello {name}"test_hello.py
import hello
def test_greet():
result = hello.greet("Syntra")
assert result == "Hello Syntra"
if __name__ == "__main__":
test_greet()Het voorbeeld is natuurlijk triviaal, maar op deze manier zou je in principe perfect al je code kunnen testen.
Het is wel vervelend om elke test-functie te moeten aanroepen in de __main__. En verschillende test_*.py bestanden moeten we dan ook allemaal gaan uitvoeren. Er komt dus wel wat extra organisatie bij kijken.
Bovendien krijgen we bij een falende test weinig informatie en wordt de uitvoering van alle overige testen onderbroken.
$ uv run test_hello.py
Traceback (most recent call last):
File "/Users/dfabrice/dev/p/syntra/tdd/test_hello.py", line 10, in <module>
test_greet()
~~~~~~~~~~^^
File "/Users/dfabrice/dev/p/syntra/tdd/test_hello.py", line 6, in test_greet
assert result == "Helllo Syntra"
^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorPytest
Onder andere om bovengenoemde redenen wordt er vrijwel altijd met een test framework gewerkt. Zo een framework zal, onder andere, deze zaken op zich nemen.
Binnen Python zijn de twee meest gekende en gebruikte frameworks:
unittest: zit in de standard Python library en organiseert testen in classes.pytest: is een moderner en erg krachtig alternatief, maar wel een apart package.
De meeste moderne projecten werken met pytest en daarom gaan we in deze les ook met dit framework verder.
Ter voorbereiding op het TDD deel maken we alvast een nieuwe repo aan (bvb. tdd) en installeren het pytest package.
# in repo directory, bvb. tdd/
git init
uv init
uv add pytestPytest zoekt automatisch naar bestanden die starten met test_. Binnen deze bestanden worden alle functies die starten met test_ gezien als testen en worden individueel uitgevoerd.
Het vorige voorbeeld kunnen we dus ongewijzigd uitvoeren (en de if __name__ == "__main__" conditie kunnen we verwijderen).
$ uv run pytest
================================= test session starts =================================
platform darwin -- Python 3.14.3, pytest-9.0.3, pluggy-1.6.0
rootdir: /Users/dfabrice/dev/p/syntra/tdd
configfile: pyproject.toml
collected 1 item
test_hello.py . [100%]
================================== 1 passed in 0.00s ==================================Ter demonstratie voegen we een tweede test toe en zorgen we dat de eerste test faalt:
test_hello.py
import hello
def test_greet_empty():
result = hello.greet("")
assert result == "Hello"
def test_greet():
result = hello.greet("Syntra")
assert result == "Hello Syntra"❯ uv run pytest
================================= test session starts =================================
platform darwin -- Python 3.14.3, pytest-9.0.3, pluggy-1.6.0
rootdir: /Users/dfabrice/dev/p/syntra/tdd
configfile: pyproject.toml
collected 2 items
test_hello.py F. [100%]
====================================== FAILURES =======================================
__________________________________ test_greet_empty ___________________________________
def test_greet_empty():
result = hello.greet("")
> assert result == "Hello"
E AssertionError: assert 'Hello ' == 'Hello'
E
E - Hello
E + Hello
E ? +
test_hello.py:6: AssertionError
=============================== short test summary info ===============================
FAILED test_hello.py::test_greet_empty - AssertionError: assert 'Hello ' == 'Hello'
============================= 1 failed, 1 passed in 0.03s =============================Alle testen worden uitgevoerd en we krijgen heel wat nuttige informatie in de output van het pytest commando!
Test Driven Development
TDD is een Feedbackloop
Test Driven Development, kortweg TDD, is een manier van software ontwikkelen waarbij je eerst een automatische test schrijft voor een klein stukje gewenst gedrag, daarna net genoeg code schrijft om die test te laten slagen, en vervolgens de code verbetert zonder het gedrag te veranderen.
Die cyclus wordt vaak samengevat als Red-Green-Refactor:
- Eerst een falende test,
- Dan werkende code,
- Daarna refactoring.
- RED Schrijf een test die faalt.
- GREEN Schrijf de eenvoudigste code die de test doet slagen.
- REFACTOR Verbeter de code terwijl alle tests groen blijven.
Refactoring is het verbeteren van de interne structuur van bestaande code zonder het externe gedrag ervan te veranderen. Met andere woorden: de code wordt leesbaarder, eenvoudiger of beter onderhoudbaar, terwijl alle tests groen blijven.
TDD werd vooral bekend binnen Extreme Programming, een voorloper op de Agile ontwikkelmethodes uit de late jaren 1990 (zie vorige les). De aanpak wordt sterk geassocieerd met Kent Beck, die TDD beschreef en populariseerde in zijn boek Test-Driven Development: By Example uit 2002. Kent Beck herkennen we natuurlijk uit de vorige les als een van de auteurs van het Agile Manifesto!
Belangrijk:
- Red is gewenst. Een falende test bewijst dat de test iets controleert. Dit is dubbel zo belangrijk bij een bugfix.
- Green is bewust minimaal. Niet meteen de perfecte oplossing bouwen.
- Tests zijn voorbeelden van gewenst gedrag. Ze documenteren wat de code moet doen.
String Calculator Kata
Kata (型, かた, kata) is een term uit de Japanse zelfverdedigingskunsten en vechtsporten zoals karate, jiujitsu, judo en in deze betekent het ‘vorm’. Een kata is een met een reeks vastgelegde bewegingen, uitgevoerd tegen 4 tot 8 denkbeeldige tegenstanders, die uit verschillende richtingen aanvallen. Het is een gedetailleerde reeks vooraf vastgestelde stoot-, trap- en afweertechnieken in zelfverdediging, die kan bestaan uit tientallen zeer uiteenlopende bewegingen en technieken. De gehele kata duurt gewoonlijk, afhankelijk van welke kata men loopt, zo’n 1 à 2 minuten.
(Bron: wikipedia)
Een CodeKata is een programmeeropdracht die je in enkele minuten tot enkele uren kunt oplossen. Net als de Karate Kata kun je deze opdrachten steeds opnieuw doen en er toch van leren. Je perfectioneert je programmeervaardigheden.
CodeKata’s worden vaak met behulp van TDD uitgevoerd en zijn dan ook een uitstekende en leuke manier om TDD concepten te demonstreren en aan te leren.
We werken verder met de klassieke String Calculator Kata, waarbij een functie een reeks getallen als string meekrijgt en de som van alle getallen teruggeeft.
Eerste Stappen
We starten met twee lege modules - één voor de code en één voor de testen. Alle testen zijn natuurlijk groen want er zijn nog helemaal geen testen (of code)!
stringcalc.py
"""String Calculator Kata"""test_stringcalc.py
"""String Calculator Kata Tests"""
import stringcalcuv add --dev pytestWe schrijven nu éérst de test voor het eenvoudigste scenario: input lege string. Het resultaat moet 0 zijn.
test_stringcalc.py
"""String Calculator Kata Tests"""
import stringcalc
def test_add_empty_string():
assert stringcalc.add("") == 0De test faalt, want er is nog helemaal geen add functie in de stringcalc module!
FAILED test_stringcalc.py::test_add_empty_string - AttributeError: module 'stringcalc' has no attribute 'add'
Dat is precies wat we willen. Onze test is rood! Wat is nu de eenvoudigste code die we kunnen schrijven in stringcalc.py om deze fout op te lossen?
Als onze test eenmaal groen is bekijken we eerst of we iets aan onze code willen verbeteren of aanpassen (refactor). In het begin zal dat meestal nog niet het geval zijn.
Als we tevreden zijn bekijken we de volgende stap: een string met één enkel getal geeft datzelfde getal terug, maar wel als int. Dus "0" geeft 0 - die test voegen we eerste toe - en "4" geeft 4.
test_stringcalc.py
"""String Calculator Kata Tests"""
import stringcalc
def test_add_empty_string():
assert stringcalc.add("") == 0
def test_add_zero():
assert stringcalc.add("0") == 0
def test_add_one_int():
assert stringcalc.add("4") == 4We gaan opnieuw op dezelfde manier te werk. Als een of meer testen rood worden zoeken we de kleinste en/of eenvoudigste aanpassing in de code tot alle testen terug groen zijn. Daarna bekijken we of de code nog verbeterd kan worden. Als we iets veranderen voeren we telkens de test terug uit om te bevestigen!
Als laatste stap behandelen we een string met verschillende getallen en een komma ertussen. Bijvoorbeeld "1,2" moet 3 geven. Als dat lukt willen we ook dat hetzelfde principe werkt met meer dan twee getallen.
test_stringcalc.py
"""String Calculator Kata Tests"""
import stringcalc
def test_add_empty_string():
assert stringcalc.add("") == 0
def test_add_zero():
assert stringcalc.add("0") == 0
def test_add_one_int():
assert stringcalc.add("4") == 4
def test_add_two_ints():
assert stringcalc.add("1,2") == 3
def test_add_many_ints():
assert stringcalc.add("1,2,3,4,5,6,7,8,9") == 45Bij TDD moeten we vaak alle testen opnieuw uitvoeren. Heel vaak. Eigenlijk na elke kleine aanpassing.
Misschien ondersteunt je IDE een handige manier om testen uit te voeren. Als alternatief kan je ook een zogenaamde file watcher tool gebruiken. Daarmee kan je automatisch een bepaalde commando laten uitvoeren telkens wanneer een of meerdere bestanden worden aangepast.
Er bestaan heel wat opties maar watchexec werkt erg gemakkelijk en is ondersteund op alle platformen.
Omdat ons project erg klein en eenvoudig is kunnen we er voor kiezen om simpelweg de testen uit te voeren bij elke aanpassing van eender welk bestand:
watchexec -- uv run pytestNewlines
Het wordt nu iets moeilijker. Om de getallen te scheiden willen we naast een komma ook een newline karakter (\n) ondersteunen (we spreken over de separator). Zo moet bijvoorbeeld "1\n2,3" als resultaat 6 geven.
test_stringcalc.py
"""String Calculator Kata Tests"""
import stringcalc
# Existing tests
def test_add_with_newline_sep():
assert stringcalc.add("1\n2,3") == 6Custom Separators
In deze stap voegen we de functie toe om optioneel een custom separator te gebruiken. Als de eerste lijn van de input start met // dan volgt daarop de custom separator, dus de vorm wordt: //<separator>\n<numbers>.
Bijvoorbeeld: "//;\n1;2" geeft 3. Alle bestaande scenarios moeten natuurlijk blijven werken.
test_stringcalc.py
"""String Calculator Kata Tests"""
import stringcalc
# Existing testsdef test_add_with_custom_sep():
assert stringcalc.add("//;\n1;2") == 3
assert stringcalc.add("//,\n1,2") == 3Als de testen terug groen kunnen we nu zeker wel enige refactoring overwegen!
TDD Nabespreking
Waarom zou je nu als ontwikkelaar TDD gebruiken i.p.v. “gewoon” eerst de applicatie-code te schrijven en dan pas de testen?
- TDD dwingt je om op voorhand grondig na te denken over de requirements van je applicatie-code.
- Tegelijk zul je ook moeten nadenken over de interface van je code. Hoe zal de functie of class er uit zien? Welke argumenten zijn nodig en wat is hun type? Wat verwachten we als return value of resultaat.
- Met TDD eindig je doorgaans met een meer complete en kwalitatievere testsuite.
- TDD dwingt je ook om code te schrijven die (gemakkelijk) testbaar is. Een tweede gevolg is dat je daar door vaak met een beter ontwerp krijgt.
Maar TDD is natuurlijk ook geen wondermiddel dat altijd en overal zinvol of noodzakelijk is.
- Met TDD komt je minder snel van start. Voor prototypes of “wegwerpcode” gaat de extra moeite grotendeels verloren.
- TDD geeft je hoofdzakelijk kleine en snelle unit tests en schept vertrouwen. Hierdoor ga je misschien functionele, end-to-end en integratietesten over het hoofd zien.
- Some krijg je pas inzicht over de beste aanpak en structuur door code te (her)schrijven. Als je grote structurele aanpassingen maakt kan het zijn dat je een hele reeks TDD unit-testen moet herschrijven.
- Niet alles leent zicht even goed tot een (pure) TDD aanpak. Denk bijvoorbeeld aan gebruikersinterfaces.
Conclusie: TDD is een krachtige tool om in jet gereedschapskist te hebben, maar blijf pragmatisch en denk goed na over de toegevoegde waarde.
Continuous Integration
Definitie
Continuous integration (CI) is de praktijk waarbij wijzigingen in de code regelmatig worden geïntegreerd en waarbij wordt gecontroleerd dat de geïntegreerde codebase in een werkbare staat blijft. Een geautomatiseerd systeem bouwt en test alle nieuwe code. Vaak draait dat automatische proces bij elke commit/push, of volgens een vast schema, bijvoorbeeld één keer per dag.
Grady Booch stelde de term CI voor het eerst voor in 1991, hoewel hij toen nog niet pleitte voor meerdere integraties per dag. Later is dat aspect wel deel gaan uitmaken van CI. Als we iets veranderen voeren we telkens de test terug uit om te bevestigen!
(Bron: wikipedia)
In het vorige deel hebben we gezien hoe TDD ervoor zorgt dat we snel en lokaal kunnen controleren dat nieuwe code doet wat we verwachten en geen bestaande functionaliteit breekt.
CI zorgt ervoor dat die controle automatisch gebeurt telkens wanneer code in een gedeelde repository wordt aangepast. Dat kan zijn na een push maar bijvoorbeeld ook na een merge (van aan feature branch naar main bvb.) of na het openen van een pull request.
Waarom spreken we dan eigenlijk over integratie? Het CI systeem kan (automatisch dus!) veel meer controleren dan enkel wat handig is om voor een ontwikkelaar zelf lokaal uit te voeren. Denk bijvoorbeeld aan:
- Samenwerking tussen verschillende componenten van een groot product. CI controleert of een gewijzigd component A nog steeds geïntegreerd kan worden met componenten B en C.
- Cross-platform compilatie. Een ontwikkelaar bouwt en test de code lokaal op een Mac maar ook Windows en Linux moeten ondersteund worden. Denk ook aan ondersteuning voor verschillende versies van die systemen!
- Specifiek voor Python willen we controleren dat de code werkt op alle ondersteunde Python versies (bvb. 3.10, 3.11, 3.12, ..).
- Sommige testen die lang duren of specifieke systemen (bijvoorbeeld een externe database) of hardware nodig hebben.
CI met GitHub Actions
Er bestaan heel wat oplossingen voor CI, zoals bijvoorbeeld Jenkins of CircleCI. Ook cloud providers bieden meestal een eigen systeem aan dat dan naadloos integreert met anderen diensten - bvb. Microsoft Azure Pipelines.
De term pipeline wordt erg vaak gebruikt als we het hebben over CI (en CD, zoals we in het volgende deel zullen zien). Het process lijkt op een pijplijn waarin iets stap voor stap door opeenvolgende fases stroomt - bvb codechange -> build -> tests -> packaging -> release
Voor deze lessenreeks blijven we gemakshalve opnieuw bij GitHub. GitHub Actions is een heel uitgebreid CI/CD platform waarmee we onmiddellijk (en gratis!) aan de slag kunnen. GitHub gebruikt de term workflows i.p.v. pipelines.
Automatisch Testen
GitHub Actions zijn tegelijk heel krachtig en eenvoudig om mee te starten! Elke repository kan een aantal workflows configureren. Elke workflow wordt gedefinieerd door een YAML bestand in de .github/workflows directory van de repo.
Een workflow bestaat uit:
- Event: Wat de workflow start, bijvoorbeeld
pushofpull_request. - Job: Een groep stappen die samen horen en na elkaar uitgevoerd worden.
- Runner: De machine waarop de job draait, bijvoorbeeld
ubuntu. - Step: Individuele stap van een Job, bijvoorbeeld dependencies installeren.
- Action: Een herbruikbare stap, bijvoorbeeld
actions/checkout
GitHub documenteert workflows als configureerbare automatische processen die één of meer jobs uitvoeren; workflows worden getriggerd door events en staan in de repository.
Een Eerste Workflow
In ons TDD project plaatsen we volgend bestand:
.github/workflows/ci.yaml
name: CI
on:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Clone the repository
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: pyproject.toml
- name: Install Python dependencies
run: uv sync --all-extras
- name: Test
run: uv run pytestAls we dit bestaan committen en pushen zal de workflow automatisch uitgevoerd worden. Het resultaat kunnen we in GitHub bekijken.
De GitHub Actions documentatie heeft ook een specifieke tutorial voor Python. Zeker de moeite om eens door te nemen - ook al wordt uv helaas (nog) niet gebruikt.
Test falen in CI
Stel dat een ontwikkelaar de string calculator module aanpast maar enkel de nieuwe functionaliteit test. In de CI workflow worden altijd alle testen uitgevoerd. Zo worden regressieproblemen onmiddellijk en voor iedereen zichtbaar.
CI Workflow Uitbreiden
GitHub heeft heel wat extra features en opties voor Workflows. We bekijken er hier een paar die vaak gebruikt worden.
Matrix
Je kan dezelfde job automatisch uitvoeren met verschillende combinaties, bijvoorbeeld meerdere Python-versies of operating systems. Handig om te testen of je code werkt op Python 3.12, 3.13 en 3.14.
.github/workflows/ci.yaml
name: CI
on:
push:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- name: Clone the repository
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install Python dependencies
run: uv sync --all-extras
- name: Test
run: uv run pytestHandmatig Starten
Een workflow hoeft niet alleen automatisch bij push of pull_request te starten. Met workflow_dispatch kan je hem manueel starten via de GitHub Actions-tab.
on:
workflow_dispatch:Artifacts Bewaren
Een workflow kan bestanden bewaren die tijdens de pipeline ontstaan, bijvoorbeeld testresultaten, logs, coverage reports of een build-output. GitHub noemt dat workflow artifacts.
- name: Save test report
uses: actions/upload-artifact@v4
with:
name: test-output
path: reports/Oefening
Voeg een CI workflow toe in de repo van je groepswerk (of een ander recent project). Heb je geen testen, dan kan je altijd enkel een actie toevoegen om met ruff de code te controleren, of met matrix te testen of je code correct installeert met verschillende Python versies.
Continuous Delivery (en Deployment)
Oorsprong
De oorsprong van de term Continuous Delivery en daarbij horende concepten vinden we in het boek Continuous Delivery van Jez Humble en David Farley uit 2010.
De beste inleiding krijg je te horen van de auteur zelf in deze talk uit 2012. We bekijken een tiental minuten van deze geweldige presentatie maar de rest is meer dan de moeite waard.
Om te situeren, in de periode 2010-2012…
- Draaien de meeste PCs nog Windows XP en is SVN nog populairder dan GIT
- Is AWS in volle exponentiële groei als leider in cloud computing
- Bestaan GitHub en Netflix nog maar een paar jaar
- Worden Instagram, Pinterest en Snapchat gelanceerd
- Zoom, Slack, Teams, … bestaan nog niet - GitHub Actions lanceert pas in 2018
Je merkt duidelijke dat dit deze materie nauw aansluit met de opkomst en popularisering van Agile. Ook de DevOps beweging situeert zich in dezelfde periode en overlapt heel sterk (CI/CD is een kernwaarde binnen DevOps).
Concepten
Traditioneel werd software vaak op een manuele en onregelmatig manier uitgerold. Een team werkte weken of maanden aan nieuwe functionaliteit, waarna op een afgesproken moment een grote release werd voorbereid. Zo’n release vroeg meestal veel handmatig werk: bestanden kopiëren, servers configureren, scripts uitvoeren, databanken aanpassen en achteraf controleren of alles nog werkte.
Dat proces was vaak spannend en risicovol. Omdat er veel wijzigingen tegelijk werden uitgerold, was het moeilijk om te weten welke wijziging een fout veroorzaakte. Releases gebeurden daarom liever niet te vaak, bijvoorbeeld één keer per maand of zelfs maar enkele keren per jaar. Daardoor werden releases nog groter, complexer en gevaarlijker. Zo ontstond een vicieuze cirkel: omdat deployen moeilijk was, deed men het weinig; omdat men het weinig deed, werd elke deployment nog moeilijker.
Continuous Delivery en Continuous Deployment proberen die cirkel te doorbreken.
Continue Delivery
Continuous Delivery, vaak afgekort als CD, is een aanpak waarbij software altijd in een toestand wordt gehouden waarin ze betrouwbaar uitgebracht kan worden.
Dat betekent niet noodzakelijk dat elke wijziging automatisch naar productie gaat. Het betekent wel dat elke wijziging automatisch door een reeks controles gaat, bijvoorbeeld:
Het resultaat van een succesvolle pipeline is een versie van de software die in principe klaar is om uitgerold te worden. De uiteindelijke releasebeslissing kan nog altijd manueel gebeuren.
Continuous Delivery draait dus vooral om releasebaarheid: de software moet op elk moment veilig en herhaalbaar uitgebracht kunnen worden.
Continuous Deployment
Continuous Deployment gaat nog een stap verder. Hierbij wordt elke wijziging die alle automatische controles doorstaat ook automatisch uitgerold naar productie.
Bij Continuous Deployment is er dus geen aparte manuele releasebeslissing meer. Als de pipeline groen is, wordt de wijziging automatisch live gezet.
Het verschil tussen de begrippen is belangrijk:
| Begrip | Betekenis |
|---|---|
| Continuous Integration | Code vaak samenvoegen en automatisch testen |
| Continuous Delivery | Software altijd klaar houden om te releasen |
| Continuous Deployment | Elke geslaagde wijziging automatisch releasen/uitrollen |
Voordelen van Continuous Delivery en Deployment
Minder risico bij releases. Omdat wijzigingen kleiner zijn en automatisch getest worden, is de kans kleiner dat een release onverwacht veel breekt. Als er toch een probleem is, is de oorzaak meestal makkelijker te vinden.
Snellere feedback. Teams zien sneller of hun code werkt. Niet alleen lokaal, maar ook in een omgeving die meer lijkt op de gedeelde of productieomgeving. Fouten worden daardoor vroeger ontdekt.
Betere kwaliteit. Automatische tests en controles worden bij elke wijziging uitgevoerd. Kwaliteit wordt daardoor geen aparte fase aan het einde, maar een continu onderdeel van het ontwikkelproces.
Sneller waarde leveren. Nieuwe functionaliteit, bugfixes en verbeteringen kunnen sneller bij gebruikers terechtkomen. Dat maakt het makkelijker om feedback van gebruikers te verzamelen en daarop verder te bouwen.
Herhaalbaar deploymentproces. Een manuele deployment hangt vaak af van documentatie, checklists en ervaring van specifieke personen. Een automatische pipeline maakt het proces expliciet, reproduceerbaar en minder afhankelijk van één individu.
Case Studies CI/CD
Links en Referenties
TDD
CI/CD
- Continuous Delivery - Jez Humble, David Farley
- Learning GitHub Actions - Brent Laster
- The DevOps Handbook - Gene Kim, Patrick Debois, John Willis, Jez Humble
- Building and testing Python (GitHub Actions docs)
- Continuous Delivery - Jez Humble
- 10+ Deploys Per Day: Dev and Ops Cooperation at Flickr - John Allspaw, Paul Hammond



