Showing preview only (3,227K chars total). Download the full file or copy to clipboard to get everything.
Repository: hjwp/Book-TDD-Web-Dev-Python
Branch: main
Commit: 4f61e6abb4a9
Files: 209
Total size: 3.0 MB
Directory structure:
gitextract_l59mn1di/
├── !README_FOR_PRODUCTION.txt
├── .dockerignore
├── .git-blame-ignore-revs
├── .github/
│ └── workflows/
│ └── tests.yml
├── .gitignore
├── .gitmodules
├── .python-version
├── CITATION.md
├── Dockerfile
├── ER_sampleTOC.html
├── LICENSE.md
├── Makefile
├── README.md
├── Vagrantfile
├── acknowledgments.asciidoc
├── ai_preface.asciidoc
├── analytics.html
├── appendix_CD.asciidoc
├── appendix_DjangoRestFramework.asciidoc
├── appendix_Django_Class-Based_Views.asciidoc
├── appendix_IV_testing_migrations.asciidoc
├── appendix_IX_cheat_sheet.asciidoc
├── appendix_X_what_to_do_next.asciidoc
├── appendix_bdd.asciidoc
├── appendix_fts_for_external_dependencies.asciidoc
├── appendix_github_links.asciidoc
├── appendix_logging.asciidoc
├── appendix_purist_unit_tests.asciidoc
├── appendix_rest_api.asciidoc
├── appendix_tradeoffs.asciidoc
├── asciidoc.conf
├── asciidoctor.css
├── atlas.json
├── author_bio.html
├── bibliography.asciidoc
├── book.asciidoc
├── buy_the_book_banner.html
├── chapter_01.asciidoc
├── chapter_02_unittest.asciidoc
├── chapter_03_unit_test_first_view.asciidoc
├── chapter_04_philosophy_and_refactoring.asciidoc
├── chapter_05_post_and_database.asciidoc
├── chapter_06_explicit_waits_1.asciidoc
├── chapter_07_working_incrementally.asciidoc
├── chapter_08_prettification.asciidoc
├── chapter_09_docker.asciidoc
├── chapter_10_production_readiness.asciidoc
├── chapter_11_server_prep.asciidoc
├── chapter_12_ansible.asciidoc
├── chapter_13_organising_test_files.asciidoc
├── chapter_14_database_layer_validation.asciidoc
├── chapter_15_simple_form.asciidoc
├── chapter_16_advanced_forms.asciidoc
├── chapter_17_javascript.asciidoc
├── chapter_18_second_deploy.asciidoc
├── chapter_19_spiking_custom_auth.asciidoc
├── chapter_20_mocking_1.asciidoc
├── chapter_21_mocking_2.asciidoc
├── chapter_22_fixtures_and_wait_decorator.asciidoc
├── chapter_23_debugging_prod.asciidoc
├── chapter_24_outside_in.asciidoc
├── chapter_25_CI.asciidoc
├── chapter_26_page_pattern.asciidoc
├── chapter_27_hot_lava.asciidoc
├── check-links.py
├── coderay-asciidoctor.css
├── colo.html
├── copy_html_to_site_and_print_toc.py
├── copyright.html
├── count-todos.py
├── cover.html
├── disqus_comments.html
├── docs/
│ ├── ORM_style_guide.htm
│ ├── ORM_style_guide_files/
│ │ └── main.css
│ ├── asciidoc-cheatsheet.html
│ ├── asciidoc-cheatsheet_files/
│ │ ├── Content.css
│ │ ├── asciidoc.asc
│ │ ├── asciidoc.css
│ │ ├── asciidoc.js
│ │ ├── jquery-1.js
│ │ └── pygments.css
│ ├── asciidoc-userguide.html
│ ├── asciidoc-userguide_files/
│ │ ├── Content.css
│ │ ├── asciidoc.css
│ │ ├── asciidoc.js
│ │ └── layout2.css
│ └── example_book.txt
├── epilogue.asciidoc
├── index.txt
├── ix.html
├── load_toc.js
├── misc/
│ ├── chapters.rst
│ ├── chapters_v2.rst
│ ├── chimera_comments_scraper.py
│ ├── curl
│ ├── get_stats.py
│ ├── get_stats.sh
│ ├── isolation-talks/
│ │ ├── djangoisland.md
│ │ ├── djangoisland.py
│ │ ├── extra_styling_for_djangoisland.css
│ │ ├── outline.txt
│ │ └── webcast-commits.hist
│ ├── plot.py
│ ├── reddit_post.md
│ ├── redditnotesresponse.txt
│ ├── tdd-flowchart.dot
│ └── tdd_diagram.odp
├── outline_and_future_chapters.asciidoc
├── part1.asciidoc
├── part2.asciidoc
├── part3.asciidoc
├── part4.asciidoc
├── praise.forbook.asciidoc
├── praise.html
├── pre-requisite-installations.asciidoc
├── preface.asciidoc
├── pygments-default.css
├── pyproject.toml
├── rename-chapter.sh
├── research/
│ ├── js-testing.rst
│ └── literary_agencies.ods
├── run_test_tests.sh
├── server-quickstart.md
├── source/
│ ├── blackify-chap.sh
│ ├── feed-thru-cherry-picks.sh
│ ├── fix-commit-numbers.py
│ └── push-back.sh
├── tests/
│ ├── actual_manage_py_test.output
│ ├── book_parser.py
│ ├── book_tester.py
│ ├── chapters.py
│ ├── check_links.py
│ ├── conftest.py
│ ├── examples.py
│ ├── my-phantomjs-qunit-runner.js
│ ├── run-js-spec.py
│ ├── slimerjs-0.9.0/
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── application.ini
│ │ ├── omni.ja
│ │ ├── slimerjs
│ │ ├── slimerjs.bat
│ │ └── slimerjs.py
│ ├── source_updater.py
│ ├── sourcetree.py
│ ├── test_appendix_DjangoRestFramework.py
│ ├── test_appendix_Django_Class-Based_Views.py
│ ├── test_appendix_bdd.py
│ ├── test_appendix_purist_unit_tests.py
│ ├── test_appendix_rest_api.py
│ ├── test_book_parser.py
│ ├── test_book_tester.py
│ ├── test_chapter_01.py
│ ├── test_chapter_02_unittest.py
│ ├── test_chapter_03_unit_test_first_view.py
│ ├── test_chapter_04_philosophy_and_refactoring.py
│ ├── test_chapter_05_post_and_database.py
│ ├── test_chapter_06_explicit_waits_1.py
│ ├── test_chapter_07_working_incrementally.py
│ ├── test_chapter_08_prettification.py
│ ├── test_chapter_09_docker.py
│ ├── test_chapter_10_production_readiness.py
│ ├── test_chapter_11_server_prep.py
│ ├── test_chapter_12_ansible.py
│ ├── test_chapter_13_organising_test_files.py
│ ├── test_chapter_14_database_layer_validation.py
│ ├── test_chapter_15_simple_form.py
│ ├── test_chapter_16_advanced_forms.py
│ ├── test_chapter_17_javascript.py
│ ├── test_chapter_19_spiking_custom_auth.py
│ ├── test_chapter_20_mocking_1.py
│ ├── test_chapter_21_mocking_2.py
│ ├── test_chapter_22_fixtures_and_wait_decorator.py
│ ├── test_chapter_23_debugging_prod.py
│ ├── test_chapter_24_outside_in.py
│ ├── test_chapter_25_CI.py
│ ├── test_chapter_26_page_pattern.py
│ ├── test_source_updater.py
│ ├── test_sourcetree.py
│ ├── test_write_to_file.py
│ ├── update_source_repo.py
│ └── write_to_file.py
├── theme/
│ ├── epub/
│ │ ├── epub.css
│ │ ├── epub.xsl
│ │ └── layout.html
│ ├── html/
│ │ └── html.xsl
│ ├── mobi/
│ │ ├── layout.html
│ │ ├── mobi.css
│ │ └── mobi.xsl
│ └── pdf/
│ ├── pdf.css
│ └── pdf.xsl
├── titlepage.html
├── toc.html
├── todos.txt
├── tools/
│ ├── figure_renaming_report.tsv
│ ├── intake_report.txt
│ └── oneoffs/
│ ├── oneoff.css
│ └── oneoff.xsl
├── video_plug.asciidoc
├── wordcount
└── workshops/
├── intermediate_workshop_notes.md
├── js-testing-with-jasmine.asciidoc
├── pycon.uk.2015.dirigible-talk.md
├── pycon.uk.2015.tutorial-beginners.md
├── pycon.us.2015.study-group.md
├── study-group.md
├── working-incrementally-handout.md
├── working-incrementally-notes.md
└── workshop.asciidoc
================================================
FILE CONTENTS
================================================
================================================
FILE: !README_FOR_PRODUCTION.txt
================================================
Note for production editor about page-breaking: the author has requested a global solution to keep code blocks from breaking across pages in the PDF, so there is CSS in place to that effect.
================================================
FILE: .dockerignore
================================================
.venv
================================================
FILE: .git-blame-ignore-revs
================================================
# matt hacker bulk edit for prod
2cb0ed8c264cee682303288ba5a5cea80956fb8d
2735e383f1281c5f200e64cfb7cda0457cfe8d1e
6fce793a9a4d701313684de11ca2cd3f5e89a041
================================================
FILE: .github/workflows/tests.yml
================================================
---
name: Book tests
on:
schedule:
- cron: "45 15 * * *"
push:
branches:
main
pull_request:
jobs:
chapter-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
test_chapter: [
test_chapter_01,
test_chapter_02_unittest,
test_chapter_03_unit_test_first_view,
test_chapter_04_philosophy_and_refactoring,
test_chapter_05_post_and_database,
test_chapter_06_explicit_waits_1,
test_chapter_07_working_incrementally,
test_chapter_08_prettification,
test_chapter_09_docker,
test_chapter_10_production_readiness,
test_chapter_11_server_prep,
test_chapter_12_ansible,
test_chapter_13_organising_test_files,
test_chapter_14_database_layer_validation,
test_chapter_15_simple_form,
test_chapter_16_advanced_forms,
test_chapter_17_javascript,
test_chapter_19_spiking_custom_auth,
test_chapter_20_mocking_1,
test_chapter_21_mocking_2,
test_chapter_22_fixtures_and_wait_decorator,
test_chapter_23_debugging_prod,
test_chapter_24_outside_in,
test_chapter_25_CI,
test_chapter_26_page_pattern,
]
env:
PY_COLORS: "1" # enable coloured output in pytest
EMAIL_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: checkout submodules
shell: bash
run: |
sed -i 's_git@github.com:_https://github.com/_' .gitmodules
git submodule init
git submodule status | cut -d" " -f2 | xargs -n1 -P0 git submodule update
- name: setup Git
shell: bash
run: |
git config --global user.email "elspeth@example.com"
git config --global user.name "Elspeth See-Eye"
git config --global init.defaultBranch main
- name: Set up Python 3.14
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install apt stuff and other dependencies
shell: bash
run: |
sudo apt remove -y --purge firefox
sudo add-apt-repository ppa:mozillateam/ppa
sudo apt update -y
sudo apt install -y \
asciidoctor \
language-pack-en \
ruby-coderay \
ruby-pygments.rb \
firefox-esr \
tree
# fix failed to install firefox bin/symlink
which firefox || sudo ln -s /usr/bin/firefox-esr /usr/bin/firefox
# remove old geckodriver
which geckodriver && sudo rm $(which geckodriver) || exit 0
pip install uv
- name: Install Python requirements.txt globally
shell: bash
run: |
uv pip install --system .
- name: Install Python requirements.txt into virtualenv
shell: bash
run: |
make .venv/bin
- name: Display firefox version
shell: bash
run: |
apt show firefox-esr
dpkg -L firefox-esr
firefox --version
which geckodriver && geckodriver --version || exit 0
- name: Run chapter test
shell: bash
run: |
make ${{ matrix.test_chapter }}
- name: Save tempdir path to an env var
if: always()
shell: bash
run: |
TMPDIR_PATH=$(cat .tmpdir.${{ matrix.test_chapter }})
echo "TMPDIR_PATH=$TMPDIR_PATH" >> $GITHUB_ENV
- name: Archive the temp dir
uses: actions/upload-artifact@v4
if: always()
with:
name: test-source-${{ matrix.test_chapter }}
path: ${{ env.TMPDIR_PATH }}
- name: Archive the built html files
uses: actions/upload-artifact@v4
if: always()
with:
name: built-html-${{ matrix.test_chapter }}
path: |
*.html
*.css
other-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install apt stuff and other dependencies
shell: bash
run: |
sudo apt update -y
sudo apt install -y \
asciidoctor \
language-pack-en \
ruby-coderay \
ruby-pygments.rb \
tree \
libxml2-utils
pip install uv
- name: Install Python requirements.txt into virtualenv
shell: bash
run: |
make .venv/bin
- name: setup Git
shell: bash
run: |
git config --global user.email "elspeth@example.com"
git config --global user.name "Elspeth See-Eye"
git config --global init.defaultBranch main
- name: prep tests submodule
shell: bash
run: |
sed -i 's_git@github.com:_https://github.com/_' .gitmodules
git submodule init
git submodule update tests/testrepo
- name: Run unit tests
shell: bash
run: |
make unit-test
- name: Run xml linter
shell: bash
run: |
make xmllint_book
================================================
FILE: .gitignore
================================================
*.pyc
.cache
.vagrant
*-cloudimg-console.log
.venv
.pytest_cache
/chapter_*.html
/appendix_*.html
/part*.html
/outline_and_future*.html
/pre-requisite-installations.html
/preface.html
/epilogue.html
/bibliography.html
/acknowledgments.html
/video_plug.html
/part*.forbook.asciidoc
/praise.forbook.html
/ai_preface.html
/misc/abandoned_roman_numerals_example
/docs/atlas_docs/
/proposals
/tags
/pdf_drafts
/pycon
/source/*/static
/source/*/database
/wordcounts.*
/feedback
/downloads/*.js
/downloads/mock*
/misc/promo/
/misc/Vagrantfile
/misc/superlists-repo-django16-backup.zip
/tdd-tutorial-materials
/misc/Invoice-Percival-1
/video
/misc/*conference_report.md
/tests/.cache/
/workshops/js-testing-with-jasmine.html
/tech review/
.vagrant.d
*.egg-info
.tmpdir.*
.env
================================================
FILE: .gitmodules
================================================
[submodule "source/chapter_01/superlists"]
path = source/chapter_01/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_02/superlists"]
path = source/chapter_02_unittest/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_03/superlists"]
path = source/chapter_03_unit_test_first_view/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_04/superlists"]
path = source/chapter_04_philosophy_and_refactoring/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_05/superlists"]
path = source/chapter_05_post_and_database/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_06/superlists"]
path = source/chapter_06_explicit_waits_1/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_07/superlists"]
path = source/chapter_07_working_incrementally/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_08/superlists"]
path = source/chapter_08_prettification/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_09/superlists"]
path = source/chapter_09_docker/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_10_production_readiness/superlists"]
path = source/chapter_10_production_readiness/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_11_server_prep/superlists"]
path = source/chapter_11_server_prep/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_12_ansible/superlists"]
path = source/chapter_12_ansible/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_13_organising_test_files/superlists"]
path = source/chapter_13_organising_test_files/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_13/superlists"]
path = source/chapter_14_database_layer_validation/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_14/superlists"]
path = source/chapter_15_simple_form/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_15/superlists"]
path = source/chapter_16_advanced_forms/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_16/superlists"]
path = source/chapter_17_javascript/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_17/superlists"]
path = source/chapter_18_second_deploy/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_18/superlists"]
path = source/chapter_19_spiking_custom_auth/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_19/superlists"]
path = source/chapter_20_mocking_1/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_21_mocking_2/superlists"]
path = source/chapter_21_mocking_2/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_22_fixtures_and_wait_decorator/superlists"]
path = source/chapter_22_fixtures_and_wait_decorator/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_21/superlists"]
path = source/chapter_23_debugging_prod/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_24_outside_in/superlists"]
path = source/chapter_24_outside_in/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_25_CI/superlists"]
path = source/chapter_25_CI/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/chapter_26_page_pattern/superlists"]
path = source/chapter_26_page_pattern/superlists
url = git@github.com:hjwp/book-example.git
[submodule "tests/testrepo"]
path = tests/testrepo
url = git@github.com:hjwp/booktesttestrepo.git
[submodule "source/appendix_II/superlists"]
path = source/appendix_Django_Class-Based_Views/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/appendix_III/superlists"]
path = source/appendix_III/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/appendix_bdd/superlists"]
path = source/appendix_bdd/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/appendix_VI_rest_api_backend/superlists"]
path = source/appendix_rest_api/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/appendix_VIII_DjangoRestFramework/superlists"]
path = source/appendix_DjangoRestFramework/superlists
url = git@github.com:hjwp/book-example.git
[submodule "source/appendix_purist_unit_tests/superlists"]
path = source/appendix_purist_unit_tests/superlists
url = git@github.com:hjwp/book-example.git
================================================
FILE: .python-version
================================================
3.14
================================================
FILE: CITATION.md
================================================
Bibtex:
```TeX
@BOOK{percival:tdd:python,
AUTHOR = "{Harry J.W.} Percival",
TITLE = "Test-Driven Development with Python",
SUBTITLE = "Obey the Testing Goat!",
DATE = "2014",
PUBLISHER = "O'Reilly Media, Inc.",
ISBN = "9781449365141"
}
```
================================================
FILE: Dockerfile
================================================
FROM python:slim
# -- WIP --
# this dockerfile is a work in progress,
# the vague intention is to use it for CI.
# RUN add-apt-repository ppa:mozillateam/ppa && \
RUN apt-get update -y
RUN apt-get install -y \
git \
asciidoctor \
# language-pack-en \
ruby-pygments.rb \
firefox-esr \
tree \
locales \
vim
RUN apt-get install -y \
make \
curl
RUN locale-gen en_GB.UTF-8
# RUN pip install uv
ADD --chmod=755 https://astral.sh/uv/install.sh /install.sh
RUN /install.sh && rm /install.sh
RUN ln -s $HOME/.local/bin/uv /usr/bin/uv
RUN git config --global user.email "elspeth@example.com" && \
git config --global user.name "Elspeth See-Eye" && \
git config --global init.defaultBranch main
WORKDIR /app
RUN uv venv .venv
COPY pyproject.toml pyproject.toml
RUN uv pip install .
RUN uv pip install selenium
ENV PATH=".venv/bin:$PATH"
CMD bash
================================================
FILE: ER_sampleTOC.html
================================================
<section data-type="preface" xmlns="http://www.w3.org/1999/xhtml">
<h1>Brief Table of Contents (<em>Not Yet Final</em>)</h1>
<p>Preface (AVAILABLE)</p>
<p>Prerequisites and Assumptions (AVAILABLE)</p>
<p>Companion Video (AVAILABLE)</p>
<p><em>Acknowledgments (UNAVAILABLE)</em></p>
<p>Part 1: The Basics of TDD and Django (AVAILABLE)</p>
<p>Chapter 1: Getting Django Set Up Using a Functional Test (AVAILABLE)</p>
<p>Chapter 2: Extending Our Functional Test Using the unittest Module (AVAILABLE)</p>
<p>Chapter 3: Testing a Simple Home Page with Unit Tests (AVAILABLE)</p>
<p>Chapter 4: What Are We Doing with All These Tests? (And, Refactoring) (AVAILABLE)</p>
<p>Chapter 5: Saving User Input: Testing the Database (AVAILABLE)</p>
<p>Chapter 6: Improving Functional Tests: Ensuring Isolation and Removing Voodoo Sleeps (AVAILABLE)</p>
<p>Chapter 7: Working Incrementally (AVAILABLE)</p>
<p>Part 2: Web Development Sine Qua Nons (AVAILABLE)</p>
<p>Chapter 8: Prettification: Layout and Styling, and What to Test About It (AVAILABLE)</p>
<p>Chapter 9: Containerization akaDocker (AVAILABLE)</p>
<p>Chapter 10: Making our App Production-Ready (AVAILABLE)</p>
<p>Chapter 11: Getting A Server Ready for Deployment (AVAILABLE)</p>
<p>Chapter 12: Infrastructure As Code: Automated Deployments With Ansible (AVAILABLE)</p>
<p>Chapter 13: Splitting Our Tests into Multiple Files, and a Generic
Wait Helper (AVAILABLE)</p>
<p>Chapter 14: Validation at the Database Layer (AVAILABLE)</p>
<p>Chapter 15: A Simple Form (AVAILABLE)</p>
<p>Chapter 16: More Advanced Forms (AVAILABLE)</p>
<p>Chapter 17: A Gentle Excursion into JavaScript (AVAILABLE)</p>
<p>Chapter 18: Deploying Our New Code (AVAILABLE)</p>
<p><em>Part 3: More Advanced Topics in Testing (UNAVAILABLE)</em></p>
<p><em>Chapter 19: User Authentication, Spiking, and De-Spiking (UNAVAILABLE)</em></p>
<p><em>Chapter 20: Mocks and Mocking 1: Using Mocks to Test External
Dependencies (UNAVAILABLE)</em></p>
<p><em>Chapter 21: Mocks and Mocking 2: Using Mocks for Test Isolation (UNAVAILABLE)</em></p>
<p><em>Chapter 22: Test Fixtures and a Decorator for Explicit Waits (UNAVAILABLE)</em></p>
<p><em>Chapter 23: Server-Side Debugging (UNAVAILABLE)</em></p>
<p><em>Chapter 24: Finishing “My Lists”: Outside-In TDD (UNAVAILABLE)</em></p>
<p><em>Chapter 25: Continuous Integration (CI) (UNAVAILABLE)</em></p>
<p><em>Chapter 26: The Token Social Bit, the Page Pattern, and an Exercise
for the Reader (UNAVAILABLE)</em></p>
<p><em>Chapter 27: Fast Tests, Slow Tests, and Hot Lava (UNAVAILABLE)</em></p>
<p><em>Back Matter: Obey the Testing Goat! (UNAVAILABLE)</em></p>
<p><em>App A: PythonAnywhere (UNAVAILABLE)</em></p>
<p><em>App B: Django Class-Based Views (UNAVAILABLE)</em></p>
<p><em>App C: Provisioning with Ansible (UNAVAILABLE)</em></p>
<p><em>App D: Testing Database Migrations (UNAVAILABLE)</em></p>
<p><em>App E: Behaviour-Driven Development (BDD) (UNAVAILABLE)</em></p>
<p><em>App F: Building a REST API: JSON, Ajax, and Mocking with JavaScript (UNAVAILABLE)</em></p>
<p><em>App G: Django-Rest-Framework (UNAVAILABLE)</em></p>
<p><em>App H: Cheat Sheet (UNAVAILABLE)</em></p>
<p><em>App I: What to Do Next (UNAVAILABLE)</em></p>
<p><em>App J: Source Code Examples (UNAVAILABLE)</em></p>
<p><em>Bibliography (UNAVAILABLE)</em></p>
</section>
================================================
FILE: LICENSE.md
================================================
This book, and all the associated source files, are being made available under
the Creative Commons Attribution-NonCommercial-ShareAlike License (v3.0 United
States)
Full info at https://creativecommons.org/licenses/by-nc-sa/3.0/us/
================================================
FILE: Makefile
================================================
SHELL := /bin/bash
SOURCES := $(wildcard *.asciidoc)
HTML_PAGES := $(patsubst %.asciidoc, %.html, ${SOURCES})
TESTS := $(patsubst %.asciidoc, test_%, ${SOURCES})
VENV ?= .venv
RUN_ASCIIDOCTOR = asciidoctor -a source-highlighter=pygments -a pygments-style=default -a stylesheet=asciidoctor.css -a linkcss -a icons=font -a compat-mode -a '!example-caption' -a last-update-label='License: Creative Commons <a href="https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode">CC-BY-NC-ND</a>. Last updated:'
export PYTHONHASHSEED = 0
export PYTHONDONTWRITEBYTECODE = 1
export MOZ_HEADLESS = 1
# for warning introduce in selenium 4.10
export PYTHONWARNINGS=ignore::ResourceWarning
export TMPDIR_CLEANUP = false
part%.forbook.asciidoc: part%.asciidoc
cat $(subst .forbook.,.,$@) \
| sed 's/^== /= /' \
| sed '/partintro/d' \
| sed '/^--$$/d' \
> $@
book.html: part1.forbook.asciidoc
book.html: part2.forbook.asciidoc
book.html: part3.forbook.asciidoc
book.html: part4.forbook.asciidoc
book.html: $(SOURCES)
%.html: %.asciidoc # build an individual chapter's html page
$(RUN_ASCIIDOCTOR) $<
.PHONY: build
build: $(HTML_PAGES) $(TMPDIR)
$(VENV)/bin:
which uv && uv venv $(VENV)|| python3 -m venv $(VENV)
which uv && uv pip install -e . || $(VENV)/bin/pip install -e .
.PHONY: install
install: $(VENV)/bin
which brew && brew install asciidoctor tree || apt install -y asciidoctor tree
.PHONY: update-submodules
update-submodules:
git submodule update --init --recursive
$(VENV)/bin/python tests/update_source_repo.py
# this is to allow for a git remote called "local" for eg ./source/feed-thru-cherry-pick.sh
../book-example.git:
mkdir -p ../book-example.git
git init --bare ../book-example.git
.PHONY: test
test: build update-submodules $(VENV)/bin
$(VENV)/bin/pytest tests/
.PHONY: testall
testall: build
$(VENV)/bin/pytest --numprocesses=auto tests/test_chapter_*
.PHONY: testall4
testall4: build
$(VENV)/bin/pytest --numprocesses=4 tests/test_chapter_*
.PHONY: test_%
test_%: %.html $(TMPDIR)
$(VENV)/bin/pytest -s --no-summary ./tests/$@.py
.PHONY: xmllint_%
xmllint_%: %.asciidoc
asciidoctor -b docbook $< -o - | sed \
-e 's/—/\—/g' \
-e 's/“/\“/g' \
-e 's/”/\”/g' \
-e 's/‘/\‘/g' \
-e 's/’/\’/g' \
-e 's/…/\…/g' \
-e 's/ /\ /g' \
-e 's/×/\×/g' \
| xmllint --noent --noout -
%.xml: %.asciidoc
asciidoctor -b docbook $<
.PHONY: check-links
check-links: book.html
python check-links.py book.html
.PHONY: clean-docker
clean-docker:
-docker kill $$(docker ps -q)
docker rmi -f busybox
docker rmi -f superlists
# env PATH=misc:$PATH
.PHONY: get-sudo
get-sudo:
sudo echo 'need sudo access for this test'
.PHONY: no-runservers
no-runservers:
-pkill -f runserver
# exhaustively list all test targets for nice tab-completion
.PHONY: test_chapter_01
test_chapter_01: chapter_01.html $(TMPDIR) $(VENV)/bin no-runservers
$(VENV)/bin/pytest -s ./tests/test_chapter_01.py
.PHONY: test_chapter_02_unittest
test_chapter_02_unittest: chapter_02_unittest.html $(TMPDIR) $(VENV)/bin no-runservers
$(VENV)/bin/pytest -s ./tests/test_chapter_02_unittest.py
.PHONY: test_chapter_03_unit_test_first_view
test_chapter_03_unit_test_first_view: chapter_03_unit_test_first_view.html $(TMPDIR) $(VENV)/bin no-runservers
$(VENV)/bin/pytest -s ./tests/test_chapter_03_unit_test_first_view.py
.PHONY: test_chapter_04_philosophy_and_refactoring
test_chapter_04_philosophy_and_refactoring: chapter_04_philosophy_and_refactoring.html $(TMPDIR) $(VENV)/bin no-runservers
$(VENV)/bin/pytest -s ./tests/test_chapter_04_philosophy_and_refactoring.py
.PHONY: test_chapter_05_post_and_database
test_chapter_05_post_and_database: chapter_05_post_and_database.html $(TMPDIR) $(VENV)/bin no-runservers
$(VENV)/bin/pytest -s ./tests/test_chapter_05_post_and_database.py
.PHONY: test_chapter_06_explicit_waits_1
test_chapter_06_explicit_waits_1: chapter_06_explicit_waits_1.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_06_explicit_waits_1.py
.PHONY: test_chapter_07_working_incrementally
test_chapter_07_working_incrementally: chapter_07_working_incrementally.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_07_working_incrementally.py
.PHONY: test_chapter_08_prettification
test_chapter_08_prettification: chapter_08_prettification.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_08_prettification.py
.PHONY: test_chapter_09_docker
test_chapter_09_docker: chapter_09_docker.html $(TMPDIR) $(VENV)/bin clean-docker
$(VENV)/bin/pytest -s ./tests/test_chapter_09_docker.py
.PHONY: test_chapter_10_production_readiness
test_chapter_10_production_readiness: get-sudo chapter_10_production_readiness.html $(TMPDIR) $(VENV)/bin clean-docker
$(VENV)/bin/pytest -s ./tests/test_chapter_10_production_readiness.py
.PHONY: test_chapter_11_server_prep
test_chapter_11_server_prep: chapter_11_server_prep.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_11_server_prep.py
.PHONY: test_chapter_13_organising_test_files
.PHONY: test_chapter_12_ansible
test_chapter_12_ansible: chapter_12_ansible.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_12_ansible.py
.PHONY: test_chapter_13_organising_test_files
test_chapter_13_organising_test_files: chapter_13_organising_test_files.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_13_organising_test_files.py
.PHONY: test_chapter_14_database_layer_validation
test_chapter_14_database_layer_validation: chapter_14_database_layer_validation.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_14_database_layer_validation.py
.PHONY: test_chapter_15_simple_form
test_chapter_15_simple_form: chapter_15_simple_form.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_15_simple_form.py
.PHONY: test_chapter_16_advanced_forms
test_chapter_16_advanced_forms: chapter_16_advanced_forms.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_16_advanced_forms.py
.PHONY: test_chapter_17_javascript
test_chapter_17_javascript: chapter_17_javascript.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_17_javascript.py
.PHONY: test_chapter_18_second_deploy
test_chapter_18_second_deploy: chapter_18_second_deploy.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_18_second_deploy.py
.PHONY: test_chapter_19_spiking_custom_auth
test_chapter_19_spiking_custom_auth: chapter_19_spiking_custom_auth.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_19_spiking_custom_auth.py
.PHONY: test_chapter_20_mocking_1
test_chapter_20_mocking_1: chapter_20_mocking_1.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_20_mocking_1.py
.PHONY: test_chapter_21_mocking_2
test_chapter_21_mocking_2: chapter_21_mocking_2.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_21_mocking_2.py
.PHONY: test_chapter_22_fixtures_and_wait_decorator
test_chapter_22_fixtures_and_wait_decorator: chapter_22_fixtures_and_wait_decorator.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_22_fixtures_and_wait_decorator.py
.PHONY: test_chapter_23_debugging_prod
test_chapter_23_debugging_prod: get-sudo chapter_23_debugging_prod.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_23_debugging_prod.py
.PHONY: test_chapter_24_outside_in
test_chapter_24_outside_in: chapter_24_outside_in.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_24_outside_in.py
.PHONY: test_chapter_25_CI
test_chapter_25_CI: chapter_25_CI.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_25_CI.py
.PHONY: test_chapter_26_page_pattern
test_chapter_26_page_pattern: chapter_26_page_pattern.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_chapter_26_page_pattern.py
.PHONY: test_appendix_purist_unit_tests
test_appendix_purist_unit_tests: appendix_purist_unit_tests.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest -s ./tests/test_appendix_purist_unit_tests.py
.PHONY: silent_test_%
silent_test_%: %.html $(TMPDIR) $(VENV)/bin
$(VENV)/bin/pytest ./tests/$(subst silent_,,$@).py
.PHONY: unit-test
unit-test: chapter_01.html $(VENV)/bin
SKIP_CHAPTER_SUBMODULES=1 ./tests/update_source_repo.py
source $(VENV)/bin/activate && ./run_test_tests.sh
# this is a hack to make 'Archive the temp dir' step work in CI
echo "tests" > .tmpdir.unit-test
.PHONY: clean
clean:
rm -rf $(TMPDIR)
rm -v $(HTML_PAGES)
================================================
FILE: README.md
================================================
# Test-Driven Web Development With Python, the book.
# License
The sources for this book are published under the Creative Commons Attribution
Non-Commercial No-Derivs license (CC-BY-NC-ND).
*I wouldn't recommend using this version to read the book. Head over to
[obeythetestinggoat.com](https://www.obeythetestinggoat.com/pages/book.html)
when you can access a nicely formatted version of the full thing, still free
and under CC license. And you'll also be able to buy an ebook or print version
if you feel like it.*
These sources are being made available for the purposes of curiosity
(although if you're curious about the way the listings are tested,
i would definitely recommend https://github.com/cosmicpython/book instead)
and collaboration (typo-fixes by pull request are very much encouraged!).
# Building the book as HTML
- install [asciidoctor](http://asciidoctor.org/), and the *pygments/pygmentize* gem.
- `make build` will build each chapter as its own html file
- `make book.html` will create a single file
- `make chapter_05_post_and_database.html`, eg, will build chapter 5
# Running the tests
* Pre-requisites for the test suite:
```console
make install
```
* Full test suite (probably, don't use this, it would take ages.)
```console
$ make test
```
* To test an individual chapter, eg:
```console
$ make test_chapter_06_explicit_waits_1
```
If you see a problem that seems to be related to submodules, try:
```console
make update-submodules
```
* Unit tests (tests for the tests for the tests in the testing book)
```console
$ ./run_test_tests.sh
```
# Windows / WSL notes
* `vagrant plugin install virtualbox_WSL2` is required
# Making changes to the book's code examples
Brief explanation: each chapter's code examples are reflected in a branch of the example code repository,
https://github.com/hjwp/book-example
in branches named after the chapter, so eg chapter_02_unittest.asciidoc has a branch called chapter_02_unittest.
These branches are actually checked out, one by one, as submodules in source/<chapter-name>/superlists.
Each branch starts at the end of the previous chapter's branch.
Code listings _mostly_ map 1 to 1 with commits in the repo,
and they are sometimes marked with little tags, eg ch03l007,
meaning theoretically, the 7th listing in chapter 3, but that's not always accurate.
When the tests run, they start by creating a new folder in /tmp
checked out with the code from the end of the last chapter.
Then they go through the code listings in the book one by one,
and simulate typing them into to an editor.
If the code listing has one of those little tags,
the tests check the commit in the repo to see if the listing matches the commit exactly.
(if there's no tag, there's some fiddly code based on string manipulation
that tries to figure out how to insert the code listing into the existing file contents at the right place)
When the tests come across a command, eg "ls",
they actually run "ls" in the temp directory,
to check whether the output that's printed in the book matches what would actually happen.
One of the most common commands is to run the tests, obviously,
so much so that if there is some console output in the book with no explicit command,
the tests assume it's a test run, so they run "./manage.py test" or equivalent.
In any case, back to our code listings - the point is that,
if we want to change one of our code listings, we also need to change the commit in the branch / submodule...
...and all of the commits that come after it.
...for that chapter and every subsequent chapter.
This is called "feeding through the changes"
## Changing a code listing
1. change the listing in the book, eg in in _chapter_03_unit_test_first_view.asciidoc_
2. open up ./source/chapter_03_unit_test_first_view/superlists in a terminal
3. do a `git rebase --interactive $previous-chapter-name`
4. identify the commit that matches the listing that you've changed, and mark it for `edit`
5. edit the file when prompted, make it match the book
6. continue the rebase, and deal with an merge conflicts as you go, woo.
7. `git push local` once you're happy.
## feeding thru the changes
Because we don't want to push WIP to github every time we change a chapter,
we use a local bare repository to push and pull chapters
```console
make ../book-example.git
```
will create it for you.
TODO: helper to do `git remote add local` to each chapter/submodule
Now you can attempt to feed thru the latest changes to this branch/chapter with
```console
cd source
./feed_thru.sh chapter_03_unit_test_first_view chapter_04_philosophy_and_refactoring
# chapter/branch names will tab complete, helpfully.
```
if all goes well, you can then run
```console
./push-back.sh chapter_04_philosophy_and_refactoring
```
and move on to the next chapter. woo!
This may all seem a bit OTT,
but the point is that if we change a variable early on in the book,
git (along with the tests) will help us to make sure that it changes
all the way through all the subsequent chapters.
================================================
FILE: Vagrantfile
================================================
# -*- mode: ruby -*-
# vi: set ft=ruby :
# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|
# The most common configuration options are documented and commented below.
# For a complete reference, please see the online documentation at
# https://docs.vagrantup.com.
# Every Vagrant development environment requires a box. You can search for
# boxes at https://vagrantcloud.com/search.
# config.vm.box = "ubuntu/jammy64" # virtualbox only
# config.vm.box = "generic/ubuntu2204" # amd64
config.vm.box = "bento/ubuntu-22.04"
# Disable automatic box update checking. If you disable this, then
# boxes will only be checked for updates when the user runs
# `vagrant box outdated`. This is not recommended.
# config.vm.box_check_update = false
# Create a forwarded port mapping which allows access to a specific port
# within the machine from a port on the host machine. In the example below,
# accessing "localhost:8080" will access port 80 on the guest machine.
# NOTE: This will enable public access to the opened port
# config.vm.network "forwarded_port", guest: 80, host: 8080
# Create a forwarded port mapping which allows access to a specific port
# within the machine from a port on the host machine and only allow access
# via 127.0.0.1 to disable public access
# config.vm.network "forwarded_port", guest: 80, host: 8080, host_ip: "127.0.0.1"
# Create a private network, which allows host-only access to the machine
# using a specific IP.
config.vm.network "private_network", ip: "192.168.56.10"
# Create a public network, which generally matched to bridged network.
# Bridged networks make the machine appear as another physical device on
# your network.
# config.vm.network "public_network"
# prevent socket thingie to stop wsl /dev/null issue
# config.vm.provider "virtualbox" do |vb|
# vb.customize [ "modifyvm", :id, "--uartmode1", "disconnected" ]
# end
# Share an additional folder to the guest VM. The first argument is
# the path on the host to the actual folder. The second argument is
# the path on the guest to mount the folder. And the optional third
# argument is a set of non-required options.
# config.vm.synced_folder "../data", "/vagrant_data"
# Disable the default share of the current code directory. Doing this
# provides improved isolation between the vagrant box and your host
# by making sure your Vagrantfile isn't accessable to the vagrant box.
# If you use this you may want to enable additional shared subfolders as
# shown above.
config.vm.synced_folder ".", "/vagrant", disabled: true
# Provider-specific configuration so you can fine-tune various
# backing providers for Vagrant. These expose provider-specific options.
# Example for VirtualBox:
#
# config.vm.provider "virtualbox" do |vb|
# # Display the VirtualBox GUI when booting the machine
# vb.gui = true
#
# # Customize the amount of memory on the VM:
# vb.memory = "1024"
# end
#
# View the documentation for the provider you are using for more
# information on available options.
# Define a Vagrant Push strategy for pushing to Atlas. Other push strategies
# such as FTP and Heroku are also available. See the documentation at
# https://docs.vagrantup.com/v2/push/atlas.html for more information.
# config.push.define "atlas" do |push|
# push.app = "YOUR_ATLAS_USERNAME/YOUR_APPLICATION_NAME"
# end
# Enable provisioning with a shell script. Additional provisioners such as
# Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the
# documentation for more information about their specific syntax and use.
ssh_pub_key = File.readlines("#{Dir.home}/.ssh/id_rsa.pub").first.strip#
config.vm.provision "shell", inline: <<-SHELL
apt update
apt upgrade -y
apt install -y dtach tree
useradd -m -s /bin/bash elspeth
usermod -a -G sudo elspeth
echo 'elspeth:elspieelspie' | chpasswd
mkdir -p /home/elspeth/.ssh
cp ~/.ssh/authorized_keys /home/elspeth/.ssh
chown elspeth /home/elspeth/.ssh/authorized_keys
echo 'elspeth ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/elspeth
echo 'export DJANGO_COLORS=nocolor' >> /home/elspeth/.profile
echo '#{ssh_pub_key}' >> /home/elspeth/.ssh/authorized_keys
SHELL
end
================================================
FILE: acknowledgments.asciidoc
================================================
[preface]
== Acknowledgments
Lots of people to thank, without whom this book would never have happened,
and/or would have been even worse than it is.
Thanks first to "Greg" at `$OTHER_PUBLISHER`, who was the first person to
encourage me to believe it really could be done. Even though your employers
turned out to have overly regressive views on copyright, I'm forever grateful
that you believed in me.
Thanks to Michael Foord, another ex-employee of Resolver Systems, for providing
the original inspiration by writing a book himself, and thanks for his ongoing
support for the project. Thanks also to my boss Giles Thomas, for foolishly
allowing another one of his employees to write a book (although I believe he's
now changed the standard employment contract to say "no books"). Thanks also
for your ongoing wisdom and for setting me off on the testing path.
Thanks to my other colleagues, Glenn Jones and Hansel Dunlop, for being
invaluable sounding boards, and for your patience with my one-track-record
conversation over the last year.
Thanks to my wife, Clementine, and to both my families—without whose support
and patience I would never have made it. I apologise for all the time spent
with my nose in the computer on what should have been memorable family occasions. I
had no idea when I set out what the book would do to my life ("Write it in my
spare time, you say? That sounds reasonable..."). I couldn't have done it
without you.
Thanks to my tech reviewers, Jonathan Hartley, Nicholas Tollervey, and Emily
Bache, for your encouragements and invaluable feedback. Especially Emily,
who actually conscientiously read every single chapter. Partial credit
to Nick and Jon, but that should still be read as eternal gratitude. Having
y'all around made the whole thing less of a lonely endeavour. Without all of
you, the book would have been little more than the nonsensical ramblings of an
idiot.
Thanks to everyone else who's given up their time to give some
feedback on the book, out of nothing more than the goodness of their heart:
Gary Bernhardt, Mark Lavin, Matt O'Donnell, Michael Foord, Hynek Schlawack,
Russell Keith-Magee, Andrew Godwin, Kenneth Reitz, and Nathan Stocks. Thanks
for being much smarter than I am, and for preventing me from saying several
stupid things. Naturally, there are still plenty of stupid things left in the
book, for which y'all can absolutely not be held responsible.
Thanks to my editor, Meghan Blanchette, for being a very friendly and likeable
slave driver, and for keeping the book on track, both in terms of timescales
and by restraining my sillier ideas. Thanks to all the others at
O'Reilly for your help, including Sarah Schneider, Kara Ebrahim, and
Dan Fauxsmith for letting me keep British English. Thanks to Charles
Roumeliotis for your help with style and grammar. We may never see eye-to-eye
on the merits of Chicago School quotation/punctuation rules, but I sure am
glad you were around. And thanks to the design department for giving us a goat
for the cover!
And thanks most especially to all my early release readers, for all your help
picking out typos, for your feedback and suggestions, for all the ways in
which you helped to smooth out the learning curve in the book, and most of
all for your kind words of encouragement and support that kept me going.
Thank you Jason Wirth, Dave Pawson, Jeff Orr, Kevin De Baere, crainbf,
dsisson, Galeran, Michael Allan, James O'Donnell, Marek Turnovec, SoonerBourne,
julz, Cody Farmer, William Vincent, Trey Hunner, David Souther, Tom Perkin,
Sorcha Bowler, Jon Poler, Charles Quast, Siddhartha Naithani, Steve Young,
Roger Camargo, Wesley Hansen, Johansen Christian Vermeer, Ian Laurain, Sean
Robertson, Hari Jayaram, Bayard Randel, Konrad Korżel, Matthew Waller, Julian
Harley, Barry McClendon, Simon Jakobi, Angelo Cordon, Jyrki Kajala, Manish
Jain, Mahadevan Sreenivasan, Konrad Korżel, Deric Crago, Cosmo Smith, Markus
Kemmerling, Andrea Costantini, Daniel Patrick, Ryan Allen, Jason Selby, Greg
Vaughan, Jonathan Sundqvist, Richard Bailey, Diane Soini, Dale Stewart, Mark
Keaton, Johan Wärlander, Simon Scarfe, Eric Grannan, Marc-Anthony Taylor,
Maria McKinley, John McKenna, Rafał Szymański, Roel van der Goot,
Ignacio Reguero, TJ Tolton, Jonathan Means, Theodor Nolte, Jungsoo Moon,
Craig Cook, Gabriel Ewilazarus, Vincenzo Pandolfo, David "farbish2", Nico
Coetzee, Daniel Gonzalez, Jared Contrascere, Zhao 赵亮,
and many, many more. If I've missed your name, you have an absolute right to be
aggrieved; I am incredibly grateful to you too, so write to me and I will try
and make it up to you in any way I can.
And finally thanks to you, the latest reader, for deciding to check out
the book! I hope you enjoy it.
[role="pagebreak-before less_space"]
=== Additional Thanks for the Second Edition
Thanks to my wonderful editor for the second edition, Nan Barber, and to
Susan Conant, Kristen Brown, and the whole team at O'Reilly.
Thanks once again to Emily and Jonathan for tech reviewing, as well as to
Edward Wong for his very thorough notes. Any remaining errors and
inadequacies are all my own.
Thanks also to the readers of the free edition who contributed comments,
suggestions, and even some pull requests. I have definitely missed some of
you on this list, so apologies if your name isn't here, but thanks to Emre
Gonulates, Jésus Gómez, Jordon Birk, James Evans, Iain Houston, Jason DeWitt,
Ronnie Raney, Spencer Ogden, Suresh Nimbalkar, Darius, Caco,
LeBodro, Jeff, Duncan Betts, wasabigeek, joegnis, Lars, Mustafa, Jared, Craig,
Sorcha, TJ, Ignacio, Roel, Justyna, Nathan, Andrea, Alexandr, bilyanhadzhi,
mosegontar, sfarzy, henziger, hunterji, das-g, juanriaza, GeoWill, Windsooon,
gonulate, Margie Roswell, Ben Elliott, Ramsay Mayka, peterj, 1hx, Wi, Duncan
Betts, Matthew Senko, Neric "Kasu" Kaz, Dominic Scotto, Andrey Makarov,
and many, many more.
=== Additional Thanks for the Third Edition
Thanks to my editor, Rita Fernando,
thanks to my tech reviewers
Béres Csanád,
David Seddon,
Sebastian Buczyński,
and Jan Giacomelli,
and thanks to all the early release readers for your feedback,
big and small, including
Jonathan H.,
James Evans,
Patrick Cantwell,
Devin Schumacher,
Nick Nielsen,
Teemu Viikeri,
Andrew Zipperer,
artGonza,
Joy Denebeim,
mshob23,
Romilly Cocking,
Zachary Kerbo,
Stephanie Goulet,
David Carter,
Jim Win Man,
Alex Kennett,
Ivan Schneider,
Lars Berberich,
Rodrigo Jacznik,
Tom Nguyen,
rokbot,
Nikita Durne,
and to anyone I've missed off this list,
my sincere apologies, ping me and I'll add you,
and thank you thank you once again.
.Extra Thanks for Csanàd
*******************************************************************************
Every single one of the tech reviewers for this edition was invaluable,
and they all contributed in different and complementary ways.
But I want to give extra thanks to Csanàd,
who went beyond the normal remit of a tech reviewer,
so far as to do substantial actual rewrites of several chapters in <<part3>>.
You can't blame him for anything in there though,
because I've been over them since,
so any errors or problems you might spot are definitely things I've added since.
Anyways, thanks so much Csanàd,
you helped me feel like I wasn't entirely alone.
*******************************************************************************
================================================
FILE: ai_preface.asciidoc
================================================
[[ai_preface]]
[preface]
== Preface to the Third Edition: [.keep-together]#TDD in the Age of AI#
Is there any point in learning TDD now that AI can write code for you?
A single prompt could probably generate the entire example application in this book,
including all its tests, and the infrastructure config for automated deployment too.
The truth is that it's too early to tell. AI is still in its infancy,
and who knows what it'll be able to do in a few years or even months' time.
=== AI Is Both Insanely Impressive and Incredibly Unreliable
What we do know is that right now,
AI is both insanely impressive and incredibly unreliable.
Beyond being able to understand and respond to prompts in normal human language--it's
easy to forget how absolutely extraordinary that is; literally science-fiction
a few years ago--AI tools can generate working code, they can generate tests,
they can help us to break down requirements, brainstorm solutions,
quickly prototype new technologies. It's genuinely astonishing.
As we're all finding out though, this all comes with a massive "but".
AI outputs are frequently plagued by hallucinations,
and in the world of code, that means things that just won't work,
even if they look plausible.
Worse than that, they can produce code that appears to work,
but is full of subtle bugs, security issues, or performance nightmares.
From a code quality point of view, we know that AI tools will often produce
code that's filled with copy-paste and duplication, weird hacks,
and undecipherable spaghetti code that spells a maintenance nightmare.
=== Mitigations for AI's Shortcomings Sure Look a Lot Like TDD
If you read the advice, even from AI companies themselves,
about the best way to work with AI, you'll find that it
performs best when working in small, well-defined contexts,
with frequent checks for correctness.
When taking on larger tasks, the advice is to break them down into smaller,
well-defined pieces, with clearly defined success criteria.
When we're thinking about the problem of hallucinations,
it sure seems like having a comprehensive test suite and running it frequently,
is going to be a must-have.
When we're thinking about code quality, the idea of having a human in the loop,
with frequent pauses for review and refactoring,
again seems like a key mitigation.
In short, all of the techniques of test-driven development that are outlined in this book:
* Defining a test that describes each small change of functionality,
before we write the code for it
* Breaking our problem down into small pieces and working incrementally,
with frequent test runs to catch bugs, regressions, and hallucinations
* The "refactor" step in TDD's red/green/refactor cycle,
which gives us a regular reminder for the human in the loop to review and improve the code.
TDD is all about finding a structured, safer way of developing software,
reducing the risk of bugs and regressions and improving code quality,
and these are very much the exact same things that we need to achieve
when working with AI.
=== Leaky Abstractions and the Importance of Experience
https://oreil.ly/PgWjL["Leaky abstractions"]
are a diagnosis of a common problem in software development,
whereby higher-level abstractions fail in subtle ways,
and the complexities of the underlying system leak through.
In the presence of leaky abstractions, you need to understand the lower-level system
to be able to work effectively.
It's for this reason that, when the switch to third-generation languages (3GLs) happened,
programmers who understood the underlying machine code were often the most effective
at using the new languages like C and Fortran.
In a similar way, AI offers us a new, higher-level abstraction around writing code,
but we can already see the "leaks" in the form of hallucinations and poor code quality.
And by analogy to the 3GLs, the programmers who are going to be most effective with AI
are going to be the ones who "know what good looks like",
both in terms of code quality, test structure, and so on,
but also in terms of what a safe and reliable workflow for software development looks like.
==== My Own Experiences with AI
In my own experiences of working with AI,
I've been very impressed at its ability to write tests, for example...
as long as there was already a good first example test to copy from.
Its ability to write that _first_ test,
the one where, as we'll see, a lot of the design (and thinking) happens in TDD,
was much more mixed.
// SEBASTIAN: Idea for a frame with actionable advice: "Make sure there are at least few examples of tests before you'll make AI assistant write more. Show it what 'good' looks like."
// SEBASTIAN: Another idea for a frame with actionable advice: "Simple things like writing a meaningful test name or describing in comment what you want to test can help immensely AI working in autocomplete mode."
Similarly when working in a less "autocomplete" and more "agentic" mode,
I saw AI tools do very well on simple problems with clear instructions,
but when trying to deal with more complex logic and requirements with ambiguity,
I've seen it get dreadfully stuck in loops and dead ends.
When that happened, I found that trying to guide the AI agent
back towards taking small steps, working on a single piece at a time,
and clarifying requirements in tests, was the best way to get things back on track.
I've also been able to experiment with using the "refactor" step
to try and improve the often-terrible code that the AI produced.
Here again I had mixed results, where the AI would need a lot of nudging
before settling on a solution that felt sufficiently readable and maintainable, to me.
So I'd echo what many others are saying, which is that AI works best
when you, the user, are a discerning partner rather than passive recipient.
NOTE: Ultimately, as software developers,
we need to be able to stand by the code we produce,
and be accountable for it,
no matter what tools were used to write it.
=== The AI-Enabled Workflow of the Future
The AI-enabled workflow of the future
will look very different to what we have now,
but all the indications are that the most effective approach is going to be
incremental, have checks and balances to avoid hallucinations,
and systematically involve humans in the loop to ensure quality.
And the closest workflow we have to that today, is TDD.
I'm excited to share it with you!
================================================
FILE: analytics.html
================================================
<html><head><script> (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); ga('create', 'UA-40928035-1', 'obeythetestinggoat.com'); ga('send', 'pageview'); </script>
================================================
FILE: appendix_CD.asciidoc
================================================
[[appendix_CD]]
[appendix]
== Continuous Deployment (CD)
.Warning
*******************************************************************************
This appendix is just a placeholder / rough sketch.
It should have the outline of what you need to set up automated deploys tho!
Why not give it a try?
*******************************************************************************
((("continuous delivery (CD)")))
This is the next step after CI.
Once we have a server that automatically does things every time we push,
we can take the next step in automating our deploys,
and deploy our code to staging (and even production!)
with every push.
NOTE: "CD" sometimes stands for Continuous Deployment,
when used to contrast with "CI",
and sometimes it stands for "Continuous Delivery",
which is basically a combination of CI and CD.
Never forget, the purpose of acronyms
is to differentiate insiders from outsiders,
so the confusion _is_ the point.
* This is an appendix because we get even more tied in to the particularities
of an individual platform
* It's also incredibly fiddly. the feedback cycle is annoying slow,
and you have to commit and push with every small change.
just look at my commit history!
[role="skipme"]
----
f5d58736 some tidyup
f28411a0 disable host key checking again
a2933ad4 dammit forgot curl
fb4132ec use private keyfile in ssh commands
ce7219e3 install ssh for fts
957ca269 fix stage name
dae47804 run fts against staging after deploy
17999c65 fix the way we get env vars in ansible script
87aecc62 make secrets files private for ssh
a06d24e9 switch off host key checking
059fc15e lets try for superverbose debug output
021843db Revert "quick look at end of keypair"
56d79af4 quick look at end of keypair
bc5664c6 fix path to secure files
857c803a install curl
c37a538c get ssh key from secure files
5ffbf80f install ssh on python image
d4f39755 duh stupid typo
c34cf933 try to deploy using gitlab registry. add stages
62486de1 docker login using password from env
4bdc6f53 fix tags in docker push to gitlab registry
c5a0056c try pushing to gitlab
81c8601f temporarily dont moujnt db
6bd41a1f forgot dind
2de01bf0 move python before-script stuff in to test step
d11c21fe try to build docker
76f15efb temporarily dont run fts
16db3dc1 debug finding path to playbook
1f3f77f5 remove backslashes
ad46cd12 just do it inline
1c887270 add deploy step
6f77b2df venv paths
801c8373 try and make actual ci work
ba8be943 Gitlab yaml config
----
Tricky!
Building and running a docker image can only be done on a `docker.git` image,
but we want `python:slim` to run our tests,
and to actually have Ansible installed
*idea 1:*
- build and push a docker image to gitlab registry after each ci run
- deploy to staging using the new image tag
- run tests against staging
*idea 2:*
- run tests inside docker (needs an image with firefox tho)
- run fts inside docker against _another_ docker container
- deploy from inside docker
I've seen variants on both of these. Gave idea 1 a go first,
and it worked out:
first (or, very quickly), i commented out the fts part of the tests.
one of the worst things about fiddling with ci is how slow it is to get feedback:
[role="sourcecode"]
..gitlab-ci.yml
====
[source,yaml]
----
test:
image: python:slim
before_script:
# TODO temporarily commented out
# - apt update -y && apt install -y firefox-esr
- python --version ; pip --version # For debugging
- pip install virtualenv
- virtualenv .venv
- source .venv/bin/activate
script:
[...]
----
====
recap:
1. run tests in python image (with firefox and our virtualenv / requirements.txt)
2. build docker image in a docker-in-docker image
3. deploy to staging (from the python image once again, needs ansible)
4. run fts against staging (from the python image, with firefox)
now, deploy playbook currently assumes we're building the docker image
as part of the deploy, but we can't do that because it happened on a different image
we could use cache / "build artifacts" to move the image around,
but we may as well do something that's more like real life.
you remember i said the `docker push / docker load` dance was a simulation
of `push+pull` from a "container registry"? well let's do that.
1. run tests (python image)
2. build our image AND push to registry (docker image)
3. deploy to staging referencing our image in the registry (python image)
4. run fts against staging (python image, with firefox)
=== Building our docker image and pushing it to Gitlab registry
TODO: gitlab container registry screnshot
[role="sourcecode"]
..gitlab-ci.yml
====
[source,yaml]
----
build:
image: docker:git
services:
- docker:dind
script:
- docker build
-t registry.gitlab.com/hjwp/book-example:$CI_COMMIT_SHA
.
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
- docker push
registry.gitlab.com/hjwp/book-example:$CI_COMMIT_SHA
----
====
link to gitlab registry docs, explain docker login, image tags.
=== Deploying from CI, working with secrets
[role="sourcecode"]
..gitlab-ci.yml
====
[source,yaml]
----
deploy:
stage: staging-deploy
image: python:slim
variables:
ANSIBLE_HOST_KEY_CHECKING: "False" # <1>
before_script:
- apt update -y && apt install -y
curl
openssh-client
- python --version ; pip --version # For debugging
- pip install virtualenv
- virtualenv .venv
- source .venv/bin/activate
script:
- pip install -r requirements.txt
- pip install ansible
# download secure files to get private key # <2>
- curl -s https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer | bash
- chmod 600 .secure_files/*
- ansible-playbook
--private-key=.secure_files/keypair-for-gitlab # <2>
--user=elspeth
-i staging.ottg.co.uk,
-vvv # <3>
${PWD}/infra/deploy-playbook.yaml
----
====
<1> "known hosts" checking doesnt work well in ci
<2> we needed a way to give the ci server permission to access our server.
I used a new ssh key
<3> super-verbose was necessary
TODO: explain generating ssh key, adding to `/home/elpseth/.ssh/authorized_keys` on server.
short listing, couple of hours of pain!
eg had to run thru about 200 lines of verbose logs to find this,
and then a bit of web-searching, to figure out that known-hosts was the problem:
[role="skipme"]
----
debug1: Server host key: ssh-ed25519 SHA256:4kXU5nf93OCxgBMuhr+OC8OUct6xb8yGsRjrqmLTJ7g
debug1: load_hostkeys: fopen /root/.ssh/known_hosts: No such file or directory
debug1: load_hostkeys: fopen /root/.ssh/known_hosts2: No such file or directory
debug1: load_hostkeys: fopen /etc/ssh/ssh_known_hosts: No such file or directory
debug1: load_hostkeys: fopen /etc/ssh/ssh_known_hosts2: No such file or directory
debug1: hostkeys_find_by_key_hostfile: hostkeys file /root/.ssh/known_hosts does not exist
debug1: hostkeys_find_by_key_hostfile: hostkeys file /root/.ssh/known_hosts2 does not exist
debug1: hostkeys_find_by_key_hostfile: hostkeys file /etc/ssh/ssh_known_hosts does not exist
debug1: hostkeys_find_by_key_hostfile: hostkeys file /etc/ssh/ssh_known_hosts2 does not exist
debug1: read_passphrase: can't open /dev/tty: No such device or address
Host key verification failed.", "unreachable": true}
----
=== Updating deploy playbook to use the container registry:
We delete all the stages to do with building locally and uploading and re-importing:
[role="sourcecode skipme"]
.infra/deploy-playbook.yaml
====
[source,diff]
----
@@ -19,37 +19,6 @@
- name: Reset ssh connection to allow the user/group change to take effect
ansible.builtin.meta: reset_connection
- - name: Build container image locally
- - name: Export container image locally
- - name: Upload image to server
- - name: Import container image on server
----
====
And instead, we can just use the full path to the image in our `docker run`
(with a login to the registry first):
[role="sourcecode skipme"]
.infra/deploy-playbook.yaml
====
[source,yaml]
----
- name: Login to gitlab container registry
community.docker.docker_login:
registry_url: "{{ lookup('env', 'CI_REGISTRY') }}" # <1>
username: "{{ lookup('env', 'CI_REGISTRY_USER') }}" # <1>
password: "{{ lookup('env', 'CI_REGISTRY_PASSWORD') }}" # <1>
- name: Run container
community.docker.docker_container:
name: superlists
image: registry.gitlab.com/hjwp/book-example:{{ lookup('env', 'CI_COMMIT_SHA') }} # <2>
state: started
recreate: true
[...]
----
====
<1> just like in the ci script, we use the env vars to get the login details
<2> and we spell out the registry, with the commit sha, in the image name
=== Running Fts against staging
Add explicit "stages" to make things run in order:
[role="sourcecode"]
..gitlab-ci.yml
====
[source,yaml]
----
stages:
- build-and-test
- staging-deploy
- staging-test
test:
image: python:slim
stage: build-and-test
[...]
build:
image: docker:git
services:
- docker:dind
stage: build-and-test
script:
[...]
test-staging:
image: python:slim
stage: staging-test
[...]
----
====
And here's how we run the tests against staging:
[role="sourcecode"]
..gitlab-ci.yml
====
[source,yaml]
----
test-staging:
image: python:slim
stage: staging-test
before_script:
- apt update -y && apt install -y
curl
firefox-esr # <1>
openssh-client
- python --version ; pip --version # For debugging
- pip install virtualenv
- virtualenv .venv
- source .venv/bin/activate
script:
- pip install -r requirements.txt
- pip install selenium
- curl -s https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer | bash
- chmod 600 .secure_files/* # <2>
- env
TEST_SERVER=staging.ottg.co.uk
SSH_PRIVATE_KEY_PATH=.secure_files/keypair-for-gitlab # <2>
python src/manage.py test functional_tests
----
====
<1> we need firefox for the fts
<2> we needed the ssh key again, because as you might remember (i forgot!)
the fts use ssh to talk to the db on the server,
to manage the database.
So we need some changes in the base FT too:
[role="sourcecode"]
.lists.tests.py (ch04l004)
====
[source,python]
----
def _exec_in_container_on_server(host, commands):
print(f"Running {commands!r} on {host} inside docker container")
keyfile = os.environ.get("SSH_PRIVATE_KEY_PATH")
keyfile_arg = ["-i", keyfile, "-o", "StrictHostKeyChecking=no"] if keyfile else [] # <1><2>
return _run_commands(
["ssh"]
+ keyfile_arg
+ [f"{USER}@{host}", "docker", "exec", "superlists"]
+ commands
)
----
====
<1> `-i` tells ssh to use a specific private key
<2> `-o StrictHostKeyChecking=no` is how we disable known_hosts for the ssh client
at the command-line
and that works
TODO it works deploy screenshot
.CD Recap
*******************************************************************************
Feedback cycles::
Slow. try to make faster.
Secrets::
secret key, email password.
each platform is different but there's always a way.
careful not to print things out!
*******************************************************************************
================================================
FILE: appendix_DjangoRestFramework.asciidoc
================================================
[[appendix_DjangoRestFramework]]
[appendix]
Django-Rest-Framework
---------------------
((("Django-Rest-Framework (DRF)", id="DRF33")))Having
"rolled our own" REST API in the last appendix, it's time to take
a look at http://www.django-rest-framework.org/[Django-Rest-Framework],
which is a go-to choice for many Python/Django developers building APIs.
Just as Django aims to give you all the basic tools that you'll need to
build a database-driven website (an ORM, templates, and so on), so DRF
aims to give you all the tools you need to build an API, and thus avoid
you having to write boilerplate code over and over again.
Writing this appendix, one of the main things I struggled with was getting the
exact same API that I'd just implemented manually to be replicated by DRF.
Getting the same URL layout and the same JSON data structures I'd defined
proved to be quite a challenge, and I felt like I was fighting the framework.
That's always a warning sign. The people who built Django-Rest-Framework
are a lot smarter than I am, and they've seen a lot more REST APIs than I
have, and if they're opinionated about the way that things "should" look,
then maybe my time would be better spent seeing if I can adapt and work
with their view of the world, rather than forcing my own preconceptions
onto it.
"Don't fight the framework" is one of the great pieces of advice I've heard.
Either go with the flow, or perhaps reassess whether you want to be using
a framework at all.
We'll work from the API we had at the end of the
https://www.obeythetestinggoat.com/book/appendix_rest_api.html[Online Appendix: Building a REST API]
and see if we can rewrite it to use DRF.
Installation
~~~~~~~~~~~~
((("Django-Rest-Framework (DRF)", "installation")))A
quick `pip install` gets us DRF. I'm just using the latest version, which
was 3.5.4 at the time of writing:
[subs="specialcharacters,quotes"]
----
$ *pip install djangorestframework*
----
And we add `rest_framework` to `INSTALLED_APPS` in 'settings.py':
[role="sourcecode"]
.superlists/settings.py
====
[source,python]
----
INSTALLED_APPS = [
#'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'lists',
'accounts',
'functional_tests',
'rest_framework',
]
----
====
Serializers (Well, ModelSerializers, Really)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("Django-Rest-Framework (DRF)", "tutorials")))((("Django-Rest-Framework (DRF)", "ModelSerializers")))The
http://bit.ly/2t6T6eX[Django-Rest-Framework tutorial]
is a pretty good resource to learn DRF. The first thing you'll come across
is serializers, and specifically in our case, "ModelSerializers". They are
DRF's way of converting from Django database models to JSON (or possibly other
formats) that you can send over the wire:
// IDEA: add an explicit unit test or two for serialization
[role="sourcecode"]
.lists/api.py (ch37l003)
====
[source,python]
----
from lists.models import List, Item
[...]
from rest_framework import routers, serializers, viewsets
class ItemSerializer(serializers.ModelSerializer):
class Meta:
model = Item
fields = ('id', 'text')
class ListSerializer(serializers.ModelSerializer):
items = ItemSerializer(many=True, source='item_set')
class Meta:
model = List
fields = ('id', 'items',)
----
====
[role="pagebreak-before"]
Viewsets (Well, ModelViewsets, Really) and Routers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("Django-Rest-Framework (DRF)", "ModelViewsets")))A
ModelViewSet is DRF's way of defining all the different ways you can interact
with the objects for a particular model via your API. Once you tell it which
models you're interested in (via the `queryset` attribute) and how to serialize
them (`serializer_class`), it will then do the rest--automatically building
views for you that will let you list, retrieve, update, and even delete objects.
Here's all we need to do for a ViewSet that'll be able to retrieve items for
a particular list:
[role="sourcecode"]
.lists/api.py (ch37l004)
====
[source,python]
----
class ListViewSet(viewsets.ModelViewSet):
queryset = List.objects.all()
serializer_class = ListSerializer
router = routers.SimpleRouter()
router.register(r'lists', ListViewSet)
----
====
A 'router' is DRF's way of building URL configuration automatically, and
mapping them to the functionality provided by the ViewSet.
At this point we can start pointing our 'urls.py' at our new router,
bypassing the old API code and seeing how our tests do with the new stuff:
[role="sourcecode"]
.superlists/urls.py (ch37l005)
====
[source,python]
----
[...]
# from lists.api import urls as api_urls
from lists.api import router
urlpatterns = [
url(r'^$', list_views.home_page, name='home'),
url(r'^lists/', include(list_urls)),
url(r'^accounts/', include(accounts_urls)),
# url(r'^api/', include(api_urls)),
url(r'^api/', include(router.urls)),
]
----
====
That makes loads of our tests fail:
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test lists*]
[...]
django.urls.exceptions.NoReverseMatch: Reverse for 'api_list' not found.
'api_list' is not a valid view function or pattern name.
[...]
AssertionError: 405 != 400
[...]
AssertionError: {'id': 2, 'items': [{'id': 2, 'text': 'item 1'}, {'id': 3,
'text': 'item 2'}]} != [{'id': 2, 'text': 'item 1'}, {'id': 3, 'text': 'item
2'}]
---------------------------------------------------------------------
Ran 54 tests in 0.243s
FAILED (failures=4, errors=10)
----
Let's take a look at those 10 errors first, all saying they cannot reverse
`api_list`. It's because the DRF router uses a different naming convention
for URLs than the one we used when we coded it manually. You'll see from the
tracebacks that they're happening when we render a template. It's 'list.html'.
We can fix that in just one place; `api_list` becomes `list-detail`:
[role="sourcecode"]
.lists/templates/list.html (ch37l006)
====
[source,html]
----
<script>
$(document).ready(function () {
var url = "{% url 'list-detail' list.id %}";
});
</script>
----
====
That will get us down to just four failures:
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test lists*]
[...]
FAIL: test_POSTing_a_new_item (lists.tests.test_api.ListAPITest)
[...]
FAIL: test_duplicate_items_error (lists.tests.test_api.ListAPITest)
[...]
FAIL: test_for_invalid_input_returns_error_code
(lists.tests.test_api.ListAPITest)
[...]
FAIL: test_get_returns_items_for_correct_list
(lists.tests.test_api.ListAPITest)
[...]
FAILED (failures=4)
----
//TODO use @skip
Let's DONT-ify all the validation tests for now, and save that complexity
for later:
[role="sourcecode"]
.lists/tests/test_api.py (ch37l007)
====
[source,python]
----
[...]
def DONTtest_for_invalid_input_nothing_saved_to_db(self):
[...]
def DONTtest_for_invalid_input_returns_error_code(self):
[...]
def DONTtest_duplicate_items_error(self):
[...]
----
====
And now we have just two failures:
[subs="specialcharacters,macros"]
----
FAIL: test_POSTing_a_new_item (lists.tests.test_api.ListAPITest)
[...]
self.assertEqual(response.status_code, 201)
AssertionError: 405 != 201
[...]
FAIL: test_get_returns_items_for_correct_list
(lists.tests.test_api.ListAPITest)
[...]
AssertionError: {'id': 2, 'items': [{'id': 2, 'text': 'item 1'}, {'id': 3,
'text': 'item 2'}]} != [{'id': 2, 'text': 'item 1'}, {'id': 3, 'text': 'item
2'}]
[...]
FAILED (failures=2)
----
Let's take a look at that last one first.
DRF's default configuration does provide a slightly different data structure
to the one we built by hand--doing a GET for a list gives you its ID, and
then the list items are inside a key called "items". That means a slight
modification to our unit test, before it gets back to passing:
[role="sourcecode"]
.lists/tests/test_api.py (ch37l008)
====
[source,diff]
----
@@ -23,10 +23,10 @@ class ListAPITest(TestCase):
response = self.client.get(self.base_url.format(our_list.id))
self.assertEqual(
json.loads(response.content.decode('utf8')),
- [
+ {'id': our_list.id, 'items': [
{'id': item1.id, 'text': item1.text},
{'id': item2.id, 'text': item2.text},
- ]
+ ]}
)
----
====
That's the GET for retrieving list items sorted (and, as we'll see later, we've
got a bunch of other stuff for free too). How about adding new ones, using
POST?
A Different URL for POST Item
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("Django-Rest-Framework (DRF)", "POST requests")))This
is the point at which I gave up on fighting the framework and just saw
where DRF wanted to take me. Although it's possible, it's quite torturous to
do a POST to the "lists" ViewSet in order to add an item to a list.
Instead, the simplest thing is to post to an item view, not a list view:
[role="sourcecode"]
.lists/api.py (ch37l009)
====
[source,python]
----
class ItemViewSet(viewsets.ModelViewSet):
serializer_class = ItemSerializer
queryset = Item.objects.all()
[...]
router.register(r'items', ItemViewSet)
----
====
So that means we change the test slightly, moving all the POST tests
out of the [keep-together]#`ListAPITest`# and into a new test class, `ItemsAPITest`:
[role="sourcecode"]
.lists/tests/test_api.py (ch37l010)
====
[source,python]
----
@@ -1,3 +1,4 @@
import json
+from django.core.urlresolvers import reverse
from django.test import TestCase
from lists.models import List, Item
@@ -31,9 +32,13 @@ class ListAPITest(TestCase):
+
+class ItemsAPITest(TestCase):
+ base_url = reverse('item-list')
+
def test_POSTing_a_new_item(self):
list_ = List.objects.create()
response = self.client.post(
- self.base_url.format(list_.id),
- {'text': 'new item'},
+ self.base_url,
+ {'list': list_.id, 'text': 'new item'},
)
self.assertEqual(response.status_code, 201)
----
====
That will give us:
----
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
----
Until we add the list ID to our serialization of items; otherwise, we don't know
what list it's for:
[role="sourcecode"]
.lists/api.py (ch37l011)
====
[source,python]
----
class ItemSerializer(serializers.ModelSerializer):
class Meta:
model = Item
fields = ('id', 'list', 'text')
----
====
And that causes another small associated test change:
[role="sourcecode"]
.lists/tests/test_api.py (ch37l012)
====
[source,python]
----
@@ -25,8 +25,8 @@ class ListAPITest(TestCase):
self.assertEqual(
json.loads(response.content.decode('utf8')),
{'id': our_list.id, 'items': [
- {'id': item1.id, 'text': item1.text},
- {'id': item2.id, 'text': item2.text},
+ {'id': item1.id, 'list': our_list.id, 'text': item1.text},
+ {'id': item2.id, 'list': our_list.id, 'text': item2.text},
]}
)
----
====
Adapting the Client Side
~~~~~~~~~~~~~~~~~~~~~~~~
((("Django-Rest-Framework (DRF)", "client-side adaptations")))Our
API no longer returns a flat array of the items in a list. It returns an
object, with a `.items` attribute that represents the items. That means a
small tweak to our +update​Items+ function:
[role="sourcecode"]
.lists/static/list.js (ch37l013)
====
[source,diff]
----
@@ -3,8 +3,8 @@ window.Superlists = {};
window.Superlists.updateItems = function (url) {
$.get(url).done(function (response) {
var rows = '';
- for (var i=0; i<response.length; i++) {
- var item = response[i];
+ for (var i=0; i<response.items.length; i++) {
+ var item = response.items[i];
rows += '\n<tr><td>' + (i+1) + ': ' + item.text + '</td></tr>';
}
$('#id_list_table').html(rows);
----
====
And because we're using different URLs for GETing lists and POSTing items,
we tweak the `initialize` function slightly too. Rather than multiple
arguments, we'll switch to using a `params` object containing the required
config:
[role="sourcecode small-code"]
.lists/static/list.js
====
[source,diff]
----
@@ -11,23 +11,24 @@ window.Superlists.updateItems = function (url) {
});
};
-window.Superlists.initialize = function (url) {
+window.Superlists.initialize = function (params) {
$('input[name="text"]').on('keypress', function () {
$('.has-error').hide();
});
- if (url) {
- window.Superlists.updateItems(url);
+ if (params) {
+ window.Superlists.updateItems(params.listApiUrl);
var form = $('#id_item_form');
form.on('submit', function(event) {
event.preventDefault();
- $.post(url, {
+ $.post(params.itemsApiUrl, {
+ 'list': params.listId,
'text': form.find('input[name="text"]').val(),
'csrfmiddlewaretoken': form.find('input[name="csrfmiddlewaretoken"]').val(),
}).done(function () {
$('.has-error').hide();
- window.Superlists.updateItems(url);
+ window.Superlists.updateItems(params.listApiUrl);
}).fail(function (xhr) {
$('.has-error').show();
if (xhr.responseJSON && xhr.responseJSON.error) {
----
====
We reflect that in 'list.html':
[role="sourcecode"]
.lists/templates/list.html (ch37l014)
====
[source,html]
----
$(document).ready(function () {
window.Superlists.initialize({
listApiUrl: "{% url 'list-detail' list.id %}",
itemsApiUrl: "{% url 'item-list' %}",
listId: {{ list.id }},
});
});
----
====
And that's actually enough to get the basic FT working again:
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test functional_tests.test_simple_list_creation*]
[...]
Ran 2 tests in 15.635s
OK
----
There's a few more changes to do with error handling, which you can explore in
the
https://github.com/hjwp/book-example/blob/appendix_DjangoRestFramework/lists/api.py[repo
for this appendix] if you're curious.
What Django-Rest-Framework Gives You
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("Django-Rest-Framework (DRF)", "benefits of")))You
may be wondering what the point of using this framework was.
Configuration Instead of Code
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Well, the first advantage is that I've transformed my old procedural view
function into a more declarative syntax:
[role="sourcecode currentcontents dofirst-ch37l016"]
.lists/api.py
====
[source,python]
----
def list(request, list_id):
list_ = List.objects.get(id=list_id)
if request.method == 'POST':
form = ExistingListItemForm(for_list=list_, data=request.POST)
if form.is_valid():
form.save()
return HttpResponse(status=201)
else:
return HttpResponse(
json.dumps({'error': form.errors['text'][0]}),
content_type='application/json',
status=400
)
item_dicts = [
{'id': item.id, 'text': item.text}
for item in list_.item_set.all()
]
return HttpResponse(
json.dumps(item_dicts),
content_type='application/json'
)
----
====
If you compare this to the final DRF version, you'll notice that we are
actually now entirely configured:
[role="sourcecode currentcontents dofirst-ch37l019"]
.lists/api.py
====
[source,python]
----
class ItemSerializer(serializers.ModelSerializer):
text = serializers.CharField(
allow_blank=False, error_messages={'blank': EMPTY_ITEM_ERROR}
)
class Meta:
model = Item
fields = ('id', 'list', 'text')
validators = [
UniqueTogetherValidator(
queryset=Item.objects.all(),
fields=('list', 'text'),
message=DUPLICATE_ITEM_ERROR
)
]
class ListSerializer(serializers.ModelSerializer):
items = ItemSerializer(many=True, source='item_set')
class Meta:
model = List
fields = ('id', 'items',)
class ListViewSet(viewsets.ModelViewSet):
queryset = List.objects.all()
serializer_class = ListSerializer
class ItemViewSet(viewsets.ModelViewSet):
serializer_class = ItemSerializer
queryset = Item.objects.all()
router = routers.SimpleRouter()
router.register(r'lists', ListViewSet)
router.register(r'items', ItemViewSet)
----
====
Free Functionality
^^^^^^^^^^^^^^^^^^
The second advantage is that, by using DRF's ModelSerializer, ViewSet, and
routers, I've actually ended up with a much more extensive API than the one I'd
rolled by hand.
* All the HTTP methods, GET, POST, PUT, PATCH, DELETE, and OPTIONS, now work,
out of the box, for all list and items URLs.
* And a browsable/self-documenting version of the API is available at
pass:[<em>http://localhost:8000/api/lists/</em>] and pass:[<em>http://localhost:8000/api/items</em>]. (<<figag01>>; try it!)
[[figag01]]
.A free browsable API for your users
image::images/twp2_ag01.png["Screenshot of DRF browsable api page at http://localhost:8000/api/items/"]
There's more information in
http://www.django-rest-framework.org/topics/documenting-your-api/#self-describing-apis[the
DRF docs], but those are both seriously neat features to be able to offer the
end users of your API.
In short, DRF is a great way of generating APIs, almost automatically, based on
your existing models structure. If you're using Django, definitely check it
out before you start hand-rolling your own API code.
.Django-Rest-Framework Tips
*******************************************************************************
((("Django-Rest-Framework (DRF)", "tips for")))Don't fight the framework::
Going with the flow is often the best way to stay productive. That, or
maybe don't use the framework. Or use it at a lower level.
Routers and ViewSets for the principle of least surprise::
One of the advantages of DRF is that its generic tools like routers and
ViewSets will give you a very predictable API, with sensible defaults
for its endpoints, URL structure, and responses for different HTTP methods.
Check out the self-documenting, browsable version::
Check out your API endpoints in a browser. DRF responds differently when it
detects your API is being accessed by a "normal" web browser, and displays
a very nice, self-documenting version of itself, which you can share with
your users.((("", startref="DRF33")))
*******************************************************************************
================================================
FILE: appendix_Django_Class-Based_Views.asciidoc
================================================
[[appendix_Django_Class-Based_Views]]
[appendix]
Django Class-Based Views
------------------------
((("Django framework", "class-based generic views", id="DJFclass28")))This
appendix follows on from <<chapter_16_advanced_forms>>, in which we
implemented Django forms for validation and refactored our views. By the end
of that chapter, our views were still using functions.
The new shiny in the Django world, however, is class-based views. In this
appendix, we'll refactor our application to use them instead of view functions.
More specifically, we'll have a go at using class-based 'generic' views.
Class-Based Generic Views
~~~~~~~~~~~~~~~~~~~~~~~~~
((("class-based generic views (CBGVs)", "vs. class-based views", secondary-sortas="class-based views")))There's
a difference between class-based views and class-based 'generic' views.
Class-based views (CBVs) are just another way of defining view functions. They make
few assumptions about what your views will do, and they offer one main
advantage over view functions, which is that they can be subclassed. This
comes, arguably, at the expense of being less readable than traditional
function-based views. The main use case for 'plain' class-based views is when
you have several views that reuse the same logic. We want to obey the DRY
principle. With function-based views, you would use helper functions or
decorators. The theory is that using a class structure may give you a more
elegant solution.
Class-based 'generic' views (CBGVs) are class-based views that attempt to provide
ready-made solutions to common use cases: fetching an object from the
database and passing it to a template, fetching a list of objects, saving
user input from a POST request using a +ModelForm+, and so on. These sound very
much like our use cases, but as we'll soon see, the devil is in the details.
I should say at this point that I've not used either kind of class-based views
much. I can definitely see the sense in them, and there are potentially many
use cases in Django apps where CBGVs would fit in perfectly. However, as soon
as your use case is slightly outside the basics--as soon as you have more
than one model you want to use, for example--I find that using class-based views
can (again, debatably) lead to code that's much harder to read than a classic
view function.
Still, because we're forced to use several of the customisation options for
class-based views, implementing them in this case can teach us a lot about
how they work, and how we can unit test them.
My hope is that the same unit tests we use for function-based views should
work just as well for class-based views. Let's see how we get on.
The Home Page as a FormView
~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("class-based generic views (CBGVs)", "home page as a FormView")))Our
home page just displays a form on a template:
[role="sourcecode currentcontents"]
.lists/views.py
====
[source,python]
----
def home_page(request):
return render(request, 'home.html', {'form': ItemForm()})
----
====
https://docs.djangoproject.com/en/5.2/ref/class-based-views/[Looking through
the options], Django has a generic view called `FormView`—let's see how
that goes:
[role="sourcecode"]
.lists/views.py (ch31l001)
====
[source,python]
----
from django.views.generic import FormView
[...]
class HomePageView(FormView):
template_name = 'home.html'
form_class = ItemForm
----
====
We tell it what template we want to use, and which form. Then, we
just need to update 'urls.py', replacing the line that used to say
`lists.views.home_page`:
[role="sourcecode"]
.superlists/urls.py (ch31l002)
====
[source,python]
----
[...]
urlpatterns = [
url(r'^$', list_views.HomePageView.as_view(), name='home'),
url(r'^lists/', include(list_urls)),
]
----
====
And the tests all check out! That was easy...
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test lists*]
[...]
Ran 34 tests in 0.119s
OK
----
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test functional_tests*]
[...]
Ran 5 tests in 15.160s
OK
----
So far, so good. We've replaced a one-line view function with a two-line class,
but it's still very readable. This would be a good time for a commit...
Using form_valid to Customise a CreateView
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("class-based generic views (CBGVs)", "customizing a CreateView", id="CBGVcreate28")))((("form_valid")))Next
we have a crack at the view we use to create a brand new list, currently
the `new_list` function. Here's what it looks like now:
[role="sourcecode currentcontents"]
.lists/views.py
====
[source,python]
----
def new_list(request):
form = ItemForm(data=request.POST)
if form.is_valid():
list_ = List.objects.create()
form.save(for_list=list_)
return redirect(list_)
else:
return render(request, 'home.html', {"form": form})
----
====
Looking through the possible CBGVs, we probably want a `CreateView`, and we
know we're using the `ItemForm` class, so let's see how we get on with them,
and whether the tests will help us:
[role="sourcecode"]
.lists/views.py (ch31l003)
====
[source,python]
----
from django.views.generic import FormView, CreateView
[...]
class NewListView(CreateView):
form_class = ItemForm
def new_list(request):
[...]
----
====
I'm going to leave the old view function in 'views.py', so that we can copy
code across from it. We can delete it once everything is working. It's
harmless as soon as we switch over the URL mappings, this time in:
[role="sourcecode"]
.lists/urls.py (ch31l004)
====
[source,python]
----
[...]
urlpatterns = [
url(r'^new$', views.NewListView.as_view(), name='new_list'),
url(r'^(\d+)/$', views.view_list, name='view_list'),
]
----
====
Now running the tests gives six errors:
[subs="specialcharacters,macros"]
[role="small-code"]
----
$ pass:quotes[*python manage.py test lists*]
[...]
ERROR: test_can_save_a_POST_request (lists.tests.test_views.NewListTest)
TypeError: save() missing 1 required positional argument: 'for_list'
ERROR: test_for_invalid_input_passes_form_to_template
(lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'
ERROR: test_for_invalid_input_renders_home_template
(lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'
ERROR: test_invalid_list_items_arent_saved (lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'
ERROR: test_redirects_after_POST (lists.tests.test_views.NewListTest)
TypeError: save() missing 1 required positional argument: 'for_list'
ERROR: test_validation_errors_are_shown_on_home_page
(lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'
FAILED (errors=6)
----
Let's start with the third--maybe we can just add the template?
[role="sourcecode"]
.lists/views.py (ch31l005)
====
[source,python]
----
class NewListView(CreateView):
form_class = ItemForm
template_name = 'home.html'
----
====
That gets us down to just two failures: we can see they're both happening
in the generic view's `form_valid` function, and that's one of the ones that
you can override to provide custom behaviour in a CBGV. As its name implies,
it's run when the view has detected a valid form. We can just copy some of
the code from our old view function, that used to live after
`if form.is_valid():`:
[role="sourcecode"]
.lists/views.py (ch31l006)
====
[source,python]
----
class NewListView(CreateView):
template_name = 'home.html'
form_class = ItemForm
def form_valid(self, form):
list_ = List.objects.create()
form.save(for_list=list_)
return redirect(list_)
----
====
That gets us a full pass!
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test lists*]
Ran 34 tests in 0.119s
OK
$ pass:quotes[*python manage.py test functional_tests*]
Ran 5 tests in 15.157s
OK
----
And we 'could' even save two more lines, trying to obey "DRY", by using one of
the main advantages of CBVs: inheritance!
[role="sourcecode"]
.lists/views.py (ch31l007)
====
[source,python]
----
class NewListView(CreateView, HomePageView):
def form_valid(self, form):
list_ = List.objects.create()
form.save(for_list=list_)
return redirect(list_)
----
====
And all the tests would still pass:
----
OK
----
WARNING: This is not really good object-oriented practice. Inheritance implies
an "is-a" relationship, and it's probably not meaningful to say that our
new list view "is-a" home page view...so, probably best not to do this.
With or without that last step, how does it compare to the old version? I'd say
that's not bad. We save some boilerplate code, and the view is still fairly
legible. So far, I'd say we've got one point for CBGVs, and one draw.((("", startref="CBGVcreate28")))
A More Complex View to Handle Both Viewing and Adding to a List
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("class-based generic views (CBGVs)", "duplicate views", id="CBGVduplicate28")))This
took me 'several' attempts. And I have to say that, although the tests
told me when I got it right, they didn't really help me to figure out the
steps to get there...mostly it was just trial and error, hacking about
in functions like `get_context_data`, `get_form_kwargs`, and so on.
One thing it did made me realise was the value of having lots of individual
tests, each testing one thing. I went back and rewrote some of Chapters pass:[<a data-type="xref" data-xrefstyle="select:labelnumber" href="#chapter_11_server_prep">#chapter_11_server_prep</a>–<a data-type="xref" data-xrefstyle="select:labelnumber" href="#chapter_13_organising_test_files">#chapter_13_organising_test_files</a>]
as a result.
The Tests Guide Us, for a While
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Here's how things might go. Start by thinking we want a `DetailView`,
something that shows you the detail of an object:
[role="sourcecode dofirst-ch31l008"]
.lists/views.py (ch31l009)
====
[source,python]
----
from django.views.generic import FormView, CreateView, DetailView
[...]
class ViewAndAddToList(DetailView):
model = List
----
====
And wiring it up in 'urls.py':
[role="sourcecode"]
.lists/urls.py (ch31l010)
====
[source,python]
----
url(r'^(\d+)/$', views.ViewAndAddToList.as_view(), name='view_list'),
----
====
That gives:
----
[...]
AttributeError: Generic detail view ViewAndAddToList must be called with either
an object pk or a slug.
FAILED (failures=5, errors=6)
----
Not totally obvious, but a bit of Googling around led me to understand that
I needed to use a "named" regex capture group:
[role="sourcecode"]
.lists/urls.py (ch31l011)
====
[source,diff]
----
@@ -3,6 +3,6 @@ from lists import views
urlpatterns = [
url(r'^new$', views.NewListView.as_view(), name='new_list'),
- url(r'^(\d+)/$', views.view_list, name='view_list'),
+ url(r'^(?P<pk>\d+)/$', views.ViewAndAddToList.as_view(), name='view_list')
]
----
====
The next set of errors had one that was fairly helpful:
----
[...]
django.template.exceptions.TemplateDoesNotExist: lists/list_detail.html
FAILED (failures=5, errors=6)
----
That's easily solved:
[role="sourcecode"]
.lists/views.py (ch31l012)
====
[source,python]
----
class ViewAndAddToList(DetailView):
model = List
template_name = 'list.html'
----
====
That takes us down five and two:
----
[...]
ERROR: test_displays_item_form (lists.tests.test_views.ListViewTest)
KeyError: 'form'
FAILED (failures=5, errors=2)
----
Until We're Left with Trial and Error
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
So I figured, our view doesn't just show us the detail of an object,
it also allows us to create new ones. Let's make it both a
`DetailView` 'and' a `CreateView`, and maybe add the `form_class`:
[role="sourcecode"]
.lists/views.py (ch31l013)
====
[source,python]
----
class ViewAndAddToList(DetailView, CreateView):
model = List
template_name = 'list.html'
form_class = ExistingListItemForm
----
====
But that gives us a lot of errors saying:
----
[...]
TypeError: __init__() missing 1 required positional argument: 'for_list'
----
And the `KeyError: 'form'` was still there too!
At this point the errors stopped being quite as helpful, and it was no longer
obvious what to do next. I had to resort to trial and error. Still, the
tests did at least tell me when I was getting things more right or more wrong.
My first attempts to use `get_form_kwargs` didn't really work, but I found
that I could use `get_form`:
[role="sourcecode"]
.lists/views.py (ch31l014)
====
[source,python]
----
def get_form(self):
self.object = self.get_object()
return self.form_class(for_list=self.object, data=self.request.POST)
----
====
But it would only work if I also assigned to `self.object`, as a side effect,
along the way, which was a bit upsetting. Still, that takes us down
to just three errors, but we're still apparently not quite there!
----
django.core.exceptions.ImproperlyConfigured: No URL to redirect to. Either
provide a url or define a get_absolute_url method on the Model.
----
Back on Track
^^^^^^^^^^^^^
And for this final failure, the tests are being helpful again.
It's quite easy to define a `get_absolute_url` on the `Item` class, such
that items point to their parent list's page:
[role="sourcecode"]
.lists/models.py (ch31l015)
====
[source,python]
----
class Item(models.Model):
[...]
def get_absolute_url(self):
return reverse('view_list', args=[self.list.id])
----
====
Is That Your Final Answer?
^^^^^^^^^^^^^^^^^^^^^^^^^^
((("", startref="CBGVduplicate28")))We
end up with a view class that looks like this:
[role="sourcecode currentcontens"]
.lists/views.py
====
[source,python]
----
class ViewAndAddToList(DetailView, CreateView):
model = List
template_name = 'list.html'
form_class = ExistingListItemForm
def get_form(self):
self.object = self.get_object()
return self.form_class(for_list=self.object, data=self.request.POST)
----
====
Compare Old and New
~~~~~~~~~~~~~~~~~~~
((("class-based generic views (CBGVs)", "comparing old and new versions")))Let's
see the old version for comparison?
[role="sourcecode currentcontents"]
.lists/views.py
====
[source,python]
----
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
form = ExistingListItemForm(for_list=list_)
if request.method == 'POST':
form = ExistingListItemForm(for_list=list_, data=request.POST)
if form.is_valid():
form.save()
return redirect(list_)
return render(request, 'list.html', {'list': list_, "form": form})
----
====
Well, it has reduced the number of lines of code from nine to seven. Still, I find
the function-based version a little easier to understand, in that it has a
little bit less magic—"explicit is better than implicit", as the Zen of
Python would have it. I mean...[keep-together]#`SingleObjectMixin`#? What? And, more
offensively, the whole thing falls apart if we don't assign to `self.object`
inside `get_form`? Yuck.
Still, I guess some of it is in the eye of the beholder.
Best Practices for Unit Testing CBGVs?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("class-based generic views (CBGVs)", "best practices for")))As
I was working through this, I felt like my "unit" tests were sometimes a
little too high-level. This is no surprise, since tests for views that involve
the Django Test Client are probably more properly called integrated tests.
They told me whether I was getting things right or wrong, but they didn't
always offer enough clues on exactly how to fix things.
I occasionally wondered whether there might be some mileage in a test that
was closer to the implementation--something like this:
[role="sourcecode skipme"]
.lists/tests/test_views.py
====
[source,python]
----
def test_cbv_gets_correct_object(self):
our_list = List.objects.create()
view = ViewAndAddToList()
view.kwargs = dict(pk=our_list.id)
self.assertEqual(view.get_object(), our_list)
----
====
But the problem is that it requires a lot of knowledge of the internals of
Django CBVs to be able to do the right test setup for these kinds of tests.
And you still end up getting very confused by the complex inheritance
hierarchy.
Take-Home: Having Multiple, Isolated View Tests with Single Assertions Helps
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
One thing I definitely did conclude from this appendix was that having many
short unit tests for views was much more helpful than having a few tests with
a narrative series of assertions.
Consider this monolithic test:
[role="sourcecode skipme"]
.lists/tests/test_views.py
====
[source,python]
----
def test_validation_errors_sent_back_to_home_page_template(self):
response = self.client.post('/lists/new', data={'text': ''})
self.assertEqual(List.objects.all().count(), 0)
self.assertEqual(Item.objects.all().count(), 0)
self.assertTemplateUsed(response, 'home.html')
expected_error = escape("You can't have an empty list item")
self.assertContains(response, expected_error)
----
====
That is definitely less useful than having three individual tests, like this:
[role="sourcecode skipme"]
.lists/tests/test_views.py
====
[source,python]
----
def test_invalid_input_means_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(List.objects.all().count(), 0)
self.assertEqual(Item.objects.all().count(), 0)
def test_invalid_input_renders_list_template(self):
response = self.post_invalid_input()
self.assertTemplateUsed(response, 'list.html')
def test_invalid_input_renders_form_with_errors(self):
response = self.post_invalid_input()
self.assertIsinstance(response.context['form'], ExistingListItemForm)
self.assertContains(response, escape(empty_list_error))
----
====
The reason is that, in the first case, an early failure means not all the
assertions are checked. So, if the view was accidentally saving to the
database on invalid POST, you would get an early fail, and so you wouldn't
find out whether it was using the right template or rendering the form. The
second formulation makes it much easier to pick out exactly what was or wasn't
working.
[role="pagebreak-before"]
.Lessons Learned from CBGVs
*******************************************************************************
Class-based generic views can do anything::
It might not always be clear what's going on, but you can do just about
anything with class-based generic views.
Single-assertion unit tests help refactoring::
((("single-assertion unit tests")))((("unit tests", "testing only one thing")))((("testing best practices")))With
each unit test providing individual guidance on what works and what
doesn't, it's much easier to change the implementation of our views to
using this fundamentally different paradigm.((("", startref="DJFclass28")))
*******************************************************************************
================================================
FILE: appendix_IV_testing_migrations.asciidoc
================================================
[[data-migrations-appendix]]
[appendix]
Testing Database Migrations
---------------------------
((("database migrations", id="dbmig30")))((("database testing", "migrations", id="DBTmig30")))Django-migrations and its predecessor South have been around for ages,
so it's not usually necessary to test database migrations. But it just
so happens that we're introducing a dangerous type of migration--that is, one
that introduces a new integrity constraint on our data. When I first ran
the migration script against staging, I saw an error.
On larger projects, where you have sensitive data, you may want the additional
confidence that comes from testing your migrations in a safe environment
before applying them to production data, so this toy example will hopefully
be a useful rehearsal.
Another common reason to want to test migrations is for speed--migrations
often involve downtime, and sometimes, when they're applied to very large
datasets, they can take time. It's good to know in advance how long that
might be.
An Attempted Deploy to Staging
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Here's what happened to me when I first tried to deploy our new validation
constraints in <<chapter_18_second_deploy>>:
[role="skipme"]
[subs="specialcharacters,macros"]
----
$ pass:quotes[*cd deploy_tools*]
$ pass:quotes[*fab deploy:host=elspeth@staging.ottg.co.uk*]
[...]
Running migrations:
Applying lists.0005_list_item_unique_together...Traceback (most recent call
last):
File "/usr/local/lib/python3.7/dist-packages/django/db/backends/utils.py",
line 61, in execute
return self.cursor.execute(sql, params)
File
"/usr/local/lib/python3.7/dist-packages/django/db/backends/sqlite3/base.py",
line 475, in execute
return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: columns list_id, text are not unique
[...]
----
What happened was that some of the existing data in the database violated
the integrity constraint, so the database was complaining when I tried to
apply it.
In order to deal with this sort of problem, we'll need to build a "data
migration". Let's first set up a local environment to test against.
Running a Test Migration Locally
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
We'll use a copy of the live database to test our migration against.
WARNING: Be very, very, very careful when using real data for testing. For
example, you may have real customer email addresses in there, and you don't
want to accidentally send them a bunch of test emails. Ask me how I know
this.
Entering Problematic Data
^^^^^^^^^^^^^^^^^^^^^^^^^
Start a list with some duplicate items on your live site, as shown in
<<dupe-data>>.
[[dupe-data]]
.A list with duplicate items
image::images/twp2_ad01.png["This list has 3 identical items"]
Copying Test Data from the Live Site
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Copy the database down from live:
[subs="specialcharacters,quotes"]
----
$ *scp elspeth@superlists.ottg.co.uk:\
/home/elspeth/sites/superlists.ottg.co.uk/database/db.sqlite3 .*
$ *mv ../database/db.sqlite3 ../database/db.sqlite3.bak*
$ *mv db.sqlite3 ../database/db.sqlite3*
----
Confirming the Error
^^^^^^^^^^^^^^^^^^^^
We now have a local database that has not been migrated, and that contains
some problematic data. We should see an error if we try to run `migrate`:
[subs="specialcharacters,quotes"]
----
$ *python manage.py migrate --migrate*
python manage.py migrate
Operations to perform:
[...]
Running migrations:
[...]
Applying lists.0005_list_item_unique_together...Traceback (most recent call
last):
[...]
return Database.Cursor.execute(self, query, params)
sqlite3.IntegrityError: columns list_id, text are not unique
----
Inserting a Data Migration
~~~~~~~~~~~~~~~~~~~~~~~~~~
https://docs.djangoproject.com/en/5.2/topics/migrations/#data-migrations[Data
migrations] are a special type of migration that modifies data in the database
rather than changing the schema. We need to create one that will run before
we apply the integrity constraint, to preventively remove any duplicates.
Here's how we can do that:
[subs="specialcharacters,macros"]
----
$ pass:quotes[*git rm lists/migrations/0005_list_item_unique_together.py*]
$ pass:quotes[*python manage.py makemigrations lists --empty*]
Migrations for 'lists':
0005_auto_20140414_2325.py:
$ pass:[<strong>mv lists/migrations/0005_*.py lists/migrations/0005_remove_duplicates.py</strong>]
----
Check out https://docs.djangoproject.com/en/5.2/topics/migrations/#data-migrations[the
Django docs on data migrations] for more info, but here's how we add some
instructions to change existing data:
[role="sourcecode"]
.lists/migrations/0005_remove_duplicates.py
====
[source,python]
----
# encoding: utf8
from django.db import models, migrations
def find_dupes(apps, schema_editor):
List = apps.get_model("lists", "List")
for list_ in List.objects.all():
items = list_.item_set.all()
texts = set()
for ix, item in enumerate(items):
if item.text in texts:
item.text = '{} ({})'.format(item.text, ix)
item.save()
texts.add(item.text)
class Migration(migrations.Migration):
dependencies = [
('lists', '0004_item_list'),
]
operations = [
migrations.RunPython(find_dupes),
]
----
====
Re-creating the Old Migration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
We re-create the old migration using `makemigrations`, which will ensure it
is now the sixth migration and has an explicit dependency on `0005`, the
data migration:
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py makemigrations*]
Migrations for 'lists':
0006_auto_20140415_0018.py:
- Alter unique_together for item (1 constraints)
$ pass:[<strong>mv lists/migrations/0006_* lists/migrations/0006_unique_together.py</strong>]
----
Testing the New Migrations Together
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
We're now ready to run our test against the live data:
[subs="specialcharacters,macros"]
----
$ pass:quotes[*cd deploy_tools*]
$ pass:quotes[*fab deploy:host=elspeth@staging.ottg.co.uk*]
[...]
----
We'll need to restart the live Gunicorn job too:
[role="server-commands skipme"]
[subs="specialcharacters,quotes"]
----
elspeth@server:$ *sudo systemctl restart gunicorn-superlists.ottg.co.uk*
----
And we can now run our FTs against staging:
[role="skipme small-code"]
[subs="specialcharacters,macros"]
----
$ pass:quotes[*STAGING_SERVER=staging.ottg.co.uk python manage.py test functional_tests*]
[...]
....
---------------------------------------------------------------------
Ran 4 tests in 17.308s
OK
----
Everything seems in order! Let's do it against live:
[role="skipme"]
[subs="specialcharacters,macros"]
----
$ pass:quotes[*fab deploy:host=superlists.ottg.co.uk*]
[superlists.ottg.co.uk] Executing task 'deploy'
[...]
----
And that's a wrap. `git add lists/migrations`, `git commit`, and so on.
Conclusions
~~~~~~~~~~~
This exercise was primarily aimed at building a data migration and testing it
against some real data. Inevitably, this is only a drop in the ocean of the
possible testing you could do for a migration. You could imagine building
automated tests to check that all your data was preserved, comparing the
database contents before and after. You could write individual unit tests
for the helper functions in a data migration. You could spend more time
measuring the time taken for migrations, and experiment with ways to speed
it up by, for example, breaking up migrations into more or fewer component steps.
Remember that this should be a relatively rare case. In my experience, I
haven't felt the need to test 99% of the migrations I've worked on. But,
should you ever feel the need on your project, I hope you've found a few
pointers here to get started with.((("", startref="dbmig30")))((("", startref="DBTmig30")))
[role="pagebreak-before less_space"]
.On Testing Database Migrations
******************************************************************************
Be wary of migrations which introduce constraints::
99% of migrations happen without a hitch, but be wary of any situations,
like this one, where you are introducing a new constraint on columns that
already exist.
Test migrations for speed::
Once you have a larger project, you should think about testing how long
your migrations are going to take. Database migrations typically involve
downtime, as, depending on your database, the schema update operation may
lock the table it's working on until it completes. It's a good idea to use
your staging site to find out how long a migration will take.
Be extremely careful if using a dump of production data::
In order to do so, you'll want fill your staging site's database with an
amount of data that's commensurate to the size of your production data.
Explaining how to do that is outside of the scope of this book, but I will
say this: if you're tempted to just take a dump of your production
database and load it into staging, be 'very' careful. Production data
contains real customer details, and I've personally been responsible for
accidentally sending out a few hundred incorrect invoices after an
automated process on my staging server started processing the copied
production data I'd just loaded into it. Not a fun afternoon.
******************************************************************************
================================================
FILE: appendix_IX_cheat_sheet.asciidoc
================================================
[[cheat-sheet]]
[appendix]
== Cheat Sheet
By popular demand, this "cheat sheet" is loosely based on the recap/summary boxes
from the end of each chapter.
The idea is to provide a few reminders,
and links to the chapters where you can find out more to jog your memory.
I hope you find it useful!
=== Initial Project Setup
* Start with a _user story_ and map it to a first _functional test_.((("cheat sheet", "project setup")))((("Django framework", "set up", "project creation")))
* Pick a test framework—`unittest` is fine, and options like `py.test`,
`nose`, or `Green` can also offer some advantages.
* Run the functional test and see your first 'expected failure'.
* Pick a web framework such as Django, and find out how to run
_unit tests_ against it.
* Create your first _unit test_ to address the current FT failure,
and see it fail.
* Do your _first commit_ to a VCS like _Git_.
Relevant chapters:
<<chapter_01>>,
<<chapter_02_unittest>>,
<<chapter_03_unit_test_first_view>>.
[role="pagebreak-before less_space"]
=== The Basic TDD Workflow: Red/Green/Refactor
[role="two-col"]
* Red, Green, Refactor((("cheat sheet", "TDD workflow")))((("Test-Driven Development (TDD)", "overall process of")))
* Double-loop TDD (<<Double-Loop-TDD-diagram2>>)
* Triangulation
* The scratchpad
* "3 Strikes and Refactor"
* "Working State to Working State"
* "YAGNI"
[[Double-Loop-TDD-diagram2]]
.Double-loop TDD
image::images/tdd3_0405.png["An inner red/green/refactor loop surrounded by an outer red/green of FTs"]
Relevant chapters:
<<chapter_04_philosophy_and_refactoring>>,
<<chapter_05_post_and_database>>,
<<chapter_07_working_incrementally>>.
=== Moving Beyond Dev-Only Testing
* Start system testing early.
Ensure your components work together: web server, static content, database.((("cheat sheet", "moving beyond dev-only testing")))
* Build a production environment early, and automate deployment to it.
- PaaS versus VPS
- Docker
- Ansible versus Terraform
* Think through deployment pain points: the database, static files,
dependencies, how to customise settings, and so on.
* Build a CI server as soon as possible, so that you don't have to rely
on self-discipline to see the tests run.
Relevant chapters:
<<part2>>,
<<chapter_25_CI>>.
[role="pagebreak-before less_space"]
=== General Testing Best Practices
* Each test should test one thing.((("cheat sheet", "testing best practices")))((("testing best practices")))
* Test behaviour rather than implementation.
* "Don't test constants".
* Try to think beyond the charmed path through the code,
and think through edge cases and error cases.
* Balance the "test desiderata".
Relevant chapters:
<<chapter_04_philosophy_and_refactoring>>,
<<chapter_14_database_layer_validation>>,
<<chapter_15_simple_form>>,
<<chapter_27_hot_lava>>.
=== Selenium/Functional Testing Best Practices
* Use explicit rather than implicit waits, and the interaction/wait pattern.
* Avoid duplication of test code--helper methods in a base class and the
page pattern are possible solutions.
* Avoid double-testing functionality.
If you have a test that covers a time-consuming process (e.g., login),
consider ways of skipping it in other tests
(but be aware of unexpected interactions between seemingly unrelated bits of functionality).
* Look into BDD tools as another way of structuring your FTs.
Relevant chapters:
<<chapter_23_debugging_prod>>,
<<chapter_25_CI>>,
<<chapter_26_page_pattern>>.
=== Outside-In
Default to working outside-in. Use double-loop TDD to drive your development,
start at the UI/outside layers, and work your way down to the infrastructure layers.
This helps ensure that you write only the code you need,
and flushes out integration issues early.
Relevant chapter: <<chapter_24_outside_in>>.
=== The Test Pyramid
Be aware that integration tests will get slower and slower over time.
Find ways to shift the bulk of your testing to unit tests
as your project grows in size and complexity.
Relevant chapter:
<<chapter_27_hot_lava>>.
================================================
FILE: appendix_X_what_to_do_next.asciidoc
================================================
[[appendix4]]
[appendix]
== What to Do Next
((("Test-Driven Development (TDD)", "future investigations", id="TDDfuture35")))
Here I offer a few suggestions for things to investigate next,
to develop your testing skills,
and to apply them to some of the cool new technologies in web development
(at the time of writing!).
I might write an article about some of these in the future.
But why not try to beat me to it,
and write your own blog post chronicling your attempt at any one of these?
((("getting help")))
I'm very happy to answer questions and provide tips and guidance
on all these topics,
so if you find yourself attempting one and getting stuck,
please don't hesitate to get in touch at obeythetestinggoat@gmail.com!
=== Switch to Postgres
SQLite is a wonderful little database, but it won't deal well once you
have more than one web worker process fielding your site's requests.
Postgres is everyone's favourite database these days,
so find out how to install and configure it.
You'll need to figure out a place to store the usernames and passwords
for your local, staging, and production Postgres servers.
Take a look at <<chapter_12_ansible>> for inspiration.
Experiment with keeping your unit tests running with SQLite,
and compare how much faster they are than running against Postgres.
Set it up so that your local machine uses SQLite for testing,
but your CI server uses Postgres.
Does any of your functionality actually depend on Postgres-specific features?
What should you do then?
=== Run Your Tests Against Different Browsers
Selenium supports all sorts of different browsers,
including Chrome, Safari, and Internet Exploder.
Try them all out and see if your FT suite behaves any differently.
In my experience, switching browsers tends to expose all sorts of race
conditions in Selenium tests, and you will probably need to use the
interaction/wait pattern a lot more.
=== The Django Admin Site
Imagine a story where a user emails you wanting to "claim" an anonymous list.
Let's say we implement a manual solution to this,
involving the site administrator manually changing the record using the Django admin site.
Find out how to switch on the admin site, and have a play with it.
Write an FT that shows a normal, non–logged-in user creating a list,
then have an admin user log in, go to the admin site, and assign the list to the user.
The user can then see it in their "My Lists" page.
=== Write Some Security Tests
Expand on the login, my lists, and sharing tests--what do you need to write to
assure yourself that users can only do what they're authorized to?
=== Test for Graceful Degradation
What would happen if our email server goes down?
Can we at least show an apologetic error message to our users?
=== Caching and Performance Testing
Find out how to install and configure `memcached`.
Find out how to use Apache's `ab` to run a performance test.
How does it perform with and without caching?
Can you write an automated test that will fail if caching is not enabled?
What about the dreaded problem of cache invalidation?
Can tests help you to make sure your cache invalidation logic is solid?
=== JavaScript Frameworks
Check out React, Vue.js, or perhaps my old favourite, Elm.
=== Async and Websockets
Supposing two users are working on the same list at the same time.
Wouldn't it be nice to see real-time updates,
so if the other person adds an item to the list, you see it immediately?
A persistent connection between client and server using websockets
is the way to get this to work.
Check out Django's async features and see if you can use them to implement dynamic notifications.
To test it, you'll need two browser instances
(like we used for the list sharing tests),
and check that notifications of the actions from one appear in the other,
without needing to refresh the page...
=== Switch to Using pytest
`pytest` lets you write unit tests with less boilerplate.
Try converting some of your unit tests to using 'py.test'.
You may need to use a plugin to get it to play nicely with Django.
=== Check Out coverage.py
Ned Batchelder's `coverage.py` will tell you what your 'test coverage' is--what
percentage of your code is covered by tests.
Now, in theory, because we've been using rigorous TDD,
we should always have 100% coverage.
But it's nice to know for sure,
and it's also a very useful tool for working on projects
that didn't have tests from the beginning.
=== Client-Side Encryption
Here's a fun one: what if our users are paranoid about the NSA, and decide they
no longer want to trust their lists to The Cloud? Can you build a JavaScript
encryption system, where the user can enter a password to encypher their list
item text before it gets sent to the server?
One way of testing it might be to have an "administrator" user that goes to
the Django admin view to inspect users' lists, and checks that they are stored
encrypted in the database.
=== Your Suggestion Here
What do you think I should put here?
Suggestions, please!
((("", startref="TDDfuture35")))
================================================
FILE: appendix_bdd.asciidoc
================================================
[[appendix_bdd]]
[appendix]
== Behaviour-Driven Development (BDD) Tools
.Warning, Content From Second Edition
*******************************************************************************
This appendix is from the second edition of the book,
so the listings have not been updated for the latest versions
of Django and Python.
As always, feedback is welcome, but especially-especially
since this stuff is all so new.
Let me know how you get on :)
*******************************************************************************
Now I haven't used the BDD tools in this appendix
for more than a few weeks in a production project,
so I can't claim any deep expertise.
But, I did like what I have seen of it,
and I thought that you deserved at least a whirlwind tour.
In this appendix, we'll take some of the tests we wrote in a
"normal" FT, and convert them to using BDD tools.
=== What Is BDD and What are BDD Tools?
((("behavior-driven development (BDD)", "defined")))
((("behavior-driven development (BDD)", id="bdd31")))
BDD itself is a practice rather than a toolset--it's
the approach of testing your application by testing the _behaviour_ that we expect it
to have, from the point of view of a user (the
https://en.wikipedia.org/wiki/Behavior-driven_development[Wikipedia entry]
has quite a good overview).
Essentially, whenever you've seen me say
"it's better to test behaviour rather than implementation",
I've been advocating for BDD.
==== Gherkin and Cucumber
((("behavior-driven development (BDD)", "tools for")))
((("Gherkin", id="gherkin31")))
((("Cucumber")))
But the term has become closely associated with a particular set of tools
for doing BDD, and particularly the
https://github.com/cucumber/cucumber/wiki/Gherkin[Gherkin syntax],
which is a human-readable DSL for writing functional (or acceptance) tests.
Gherkin originally came out of the Ruby world,
where it's associated with a test runner called
https://cucumber.io/[Cucumber].
We'll be talking about these tools in this appendix.
TIP: BDD as a practice is not the same as the toolset and the Gherkin syntax
((("Lettuce")))
((("Behave")))
In the Python world, we have a couple of equivalent test running tools,
http://lettuce.it/[Lettuce] and http://pythonhosted.org/behave/[Behave].
Of these, only Behave was compatible with Python 3 at the time of writing,
so that's what we'll use.
We'll also use a plugin called
https://pythonhosted.org/behave-django/[behave-django].
[role="pagebreak-before"]
.Getting the Code for These Examples
**********************************************************************
((("code examples, obtaining and using")))
I'm going to use the example from <<chapter_24_outside_in>>.
We have a basic to-do lists site, and we want to add a new feature:
logged-in users should be able to view the lists they've authored in one place.
Up until this point, all lists are effectively anonymous.
If you've been following along with the book, I'm going to assume you can skip
back to the code for that point. If you want to pull it from my repo, the
place to go is the
https://github.com/hjwp/book-example/tree/chapter_17[chapter_17 branch].
**********************************************************************
=== Basic Housekeeping
((("behavior-driven development (BDD)", "directory creation")))We
make a directory for our BDD "features," add a _steps_ directory (we'll find
out what these are shortly!), and placeholder for our first feature:
[subs="specialcharacters,quotes"]
----
$ *mkdir -p features/steps*
$ *touch features/my_lists.feature*
$ *touch features/steps/my_lists.py*
$ *tree features*
features
├── my_lists.feature
└── steps
└── my_lists.py
----
We install `behave-django`, and add it to _settings.py_:
[role="dofirst-ch35l000"]
[subs="specialcharacters,quotes"]
----
$ *pip install behave-django*
----
[role="sourcecode"]
.superlists/settings.py
====
[source,diff]
----
--- a/superlists/settings.py
+++ b/superlists/settings.py
@@ -40,6 +40,7 @@ INSTALLED_APPS = [
'lists',
'accounts',
'functional_tests',
+ 'behave_django',
]
----
====
And then run `python manage.py behave` as a sanity check:
[subs=""]
----
$ <strong>python manage.py behave</strong>
Creating test database for alias 'default'...
0 features passed, 0 failed, 0 skipped
0 scenarios passed, 0 failed, 0 skipped
0 steps passed, 0 failed, 0 skipped, 0 undefined
Took 0m0.000s
Destroying test database for alias 'default'...
----
=== Writing an FT as a "Feature" Using Gherkin Syntax
((("behavior-driven development (BDD)", "functional test using Gherkin syntax")))
((("functional tests (FTs)", "using Gherkin syntax", secondary-sortas="Gherkin syntax")))
Up until now, we've been writing our FTs using human-readable comments
that describe the new feature in terms of a user story, interspersed
with the Selenium code required to execute each step in the story.
BDD enforces a distinction between those two--we write our human-readable
story using a human-readable (if occasionally somewhat awkward) syntax
called "Gherkin", and that is called the "Feature". Later, we'll map
each line of Gherkin to a function that contains the Selenium code necessary
to implement that "step."
Here's what a Feature for our new "My lists" page could look like:
[role="sourcecode"]
.features/my_lists.feature
====
[source,gherkin]
----
Feature: My Lists
As a logged-in user
I want to be able to see all my lists in one page
So that I can find them all after I've written them
Scenario: Create two lists and see them on the My Lists page
Given I am a logged-in user
When I create a list with first item "Reticulate Splines"
And I add an item "Immanentize Eschaton"
And I create a list with first item "Buy milk"
Then I will see a link to "My lists"
When I click the link to "My lists"
Then I will see a link to "Reticulate Splines"
And I will see a link to "Buy milk"
When I click the link to "Reticulate Splines"
Then I will be on the "Reticulate Splines" list page
----
====
[role="pagebreak-before"]
==== As-a /I want to/So that
At the top you'll notice the As-a/I want to/So that clause. This is
optional, and it has no executable counterpart--it's just a slightly
formalised way of capturing the "who and why?" aspects of a user story,
gently encouraging the team to think about the justifications for each
feature.
==== Given/When/Then
Given/When/Then is the real core of a BDD test. This trilobite formulation
matches the setup/exercise/assert pattern we've seen in our unit tests, and
it represents the setup and assumptions phase, an exercise/action phase, and
a subsequent assertion/observation phase. There's more info on the
https://github.com/cucumber/cucumber/wiki/Given-When-Then[Cucumber wiki].
==== Not Always a Perfect Fit!
As you can see, it's not always easy to shoe-horn a user story into exactly
three steps! We can use the `And` clause to expand on a step, and I've
added multiple `When` steps and subsequent `Then`'s to illustrate further
aspects of our "My lists" page.((("", startref="gherkin31")))
=== Coding the Step Functions
((("behavior-driven development (BDD)", "step functions")))
We now build the counterpart to our Gherkin-syntax feature,
which are the "step" functions that will actually implement them in code.
==== Generating Placeholder Steps
When we run `behave`, it helpfully tells us about all the steps we need to
implement:
[role="small-code"]
[subs="specialcharacters,quotes"]
----
$ *python manage.py behave*
Feature: My Lists # features/my_lists.feature:1
As a logged-in user
I want to be able to see all my lists in one page
So that I can find them all after I've written them
Scenario: Create two lists and see them on the My Lists page #
features/my_lists.feature:6
Given I am a logged-in user # None
Given I am a logged-in user # None
When I create a list with first item "Reticulate Splines" # None
And I add an item "Immanentize Eschaton" # None
And I create a list with first item "Buy milk" # None
Then I will see a link to "My lists" # None
When I click the link to "My lists" # None
Then I will see a link to "Reticulate Splines" # None
And I will see a link to "Buy milk" # None
When I click the link to "Reticulate Splines" # None
Then I will be on the "Reticulate Splines" list page # None
Failing scenarios:
features/my_lists.feature:6 Create two lists and see them on the My Lists
page
0 features passed, 1 failed, 0 skipped
0 scenarios passed, 1 failed, 0 skipped
0 steps passed, 0 failed, 0 skipped, 10 undefined
Took 0m0.000s
You can implement step definitions for undefined steps with these snippets:
@given(u'I am a logged-in user')
def step_impl(context):
raise NotImplementedError(u'STEP: Given I am a logged-in user')
@when(u'I create a list with first item "Reticulate Splines"')
def step_impl(context):
[...]
----
And you'll notice all this output is nicely coloured, as shown in
<<behave-output>>.
[[behave-output]]
.Behave with coloured console ouptut
image::images/twp2_ae01.png["Colourful console output"]
It's encouraging us to copy and paste these snippets, and use them as
starting points to build our steps.
=== First Step Definition
Here's a first stab at making a step for our "Given I am a logged-in user"
step. I started by stealing the code for `self.create_pre_authenticated_session`
from 'functional_tests/test_my_lists.py', and adapting it slightly (removing
the server-side version, for example, although it would be easy to re-add
later).
[role="sourcecode small-code"]
.features/steps/my_lists.py
====
[source,python]
----
from behave import given, when, then
from functional_tests.management.commands.create_session import \
create_pre_authenticated_session
from django.conf import settings
@given('I am a logged-in user')
def given_i_am_logged_in(context):
session_key = create_pre_authenticated_session(email='edith@example.com')
## to set a cookie we need to first visit the domain.
## 404 pages load the quickest!
context.browser.get(context.get_url("/404_no_such_url/"))
context.browser.add_cookie(dict(
name=settings.SESSION_COOKIE_NAME,
value=session_key,
path='/',
))
----
====
//ch35l004
The 'context' variable needs a little explaining—it's a sort of global
variable, in the sense that it's passed to each step that's executed, and it
can be used to store information that we need to share between steps. Here
we've assumed we'll be storing a browser object on it, and the `server_url`.
We end up using it a lot like we used `self` when we were writing `unittest`
FTs.
=== setUp and tearDown Equivalents in environment.py
Steps can make changes to state in the `context`, but the place to do
preliminary set-up, the equivalent of `setUp`, is in a file called
_environment.py_:
[role="sourcecode"]
.features/environment.py
====
[source,python]
----
from selenium import webdriver
def before_all(context):
context.browser = webdriver.Firefox()
def after_all(context):
context.browser.quit()
def before_feature(context, feature):
pass
----
====
//ch35l005
=== Another Run
As a sanity check, we can do another run, to see if the new step works and
that we really can start a browser:
[subs="specialcharacters,quotes"]
----
$ *python manage.py behave*
[...]
1 step passed, 0 failed, 0 skipped, 9 undefined
----
The usual reams of output, but we can see that it seems to have made it through
the first step; let's define the rest of them.
=== Capturing Parameters in Steps
((("behavior-driven development (BDD)", "capturing parameters in steps")))We'll
see how Behave allows you to capture parameters from step descriptions.
Our next step says:
[role="sourcecode currentcontents"]
.features/my_lists.feature
====
[source,gherkin]
----
When I create a list with first item "Reticulate Splines"
----
====
And the autogenerated step definition looked like this:
[role="sourcecode small-code skipme"]
.features/steps/my_lists.py
====
[source,python]
----
@given('I create a list with first item "Reticulate Splines"')
def step_impl(context):
raise NotImplementedError(
u'STEP: When I create a list with first item "Reticulate Splines"'
)
----
====
We want to be able to create lists with arbitrary first items, so it would be
nice to somehow capture whatever is between those quotes, and pass them in as
an argument to a more generic function. That's a common requirement in BDD,
and Behave has a nice syntax for it, reminiscent of the new-style Python string
formatting syntax:
[role="sourcecode"]
.features/steps/my_lists.py (ch35l006)
====
[source,python]
----
[...]
@when('I create a list with first item "{first_item_text}"')
def create_a_list(context, first_item_text):
context.browser.get(context.get_url('/'))
context.browser.find_element(By.ID, 'id_text').send_keys(first_item_text)
context.browser.find_element(By.ID, 'id_text').send_keys(Keys.ENTER)
wait_for_list_item(context, first_item_text)
----
====
Neat, huh?
NOTE: Capturing parameters for steps is one of the most powerful features
of the BDD syntax.
As usual with Selenium tests, we will need an explicit wait. Let's re-use
our `@wait` decorator from 'base.py':
[role="sourcecode"]
.features/steps/my_lists.py (ch35l007)
====
[source,python]
----
from functional_tests.base import wait
[...]
@wait
def wait_for_list_item(context, item_text):
context.test.assertIn(
item_text,
context.browser.find_element_by_css_selector('#id_list_table').text
)
----
====
Similarly, we can add to an existing list, and see or click on links:
[role="sourcecode"]
.features/steps/my_lists.py (ch35l008)
====
[source,python]
----
from selenium.webdriver.common.keys import Keys
[...]
@when('I add an item "{item_text}"')
def add_an_item(context, item_text):
context.browser.find_element(By.ID, 'id_text').send_keys(item_text)
context.browser.find_element(By.ID, 'id_text').send_keys(Keys.ENTER)
wait_for_list_item(context, item_text)
@then('I will see a link to "{link_text}"')
@wait
def see_a_link(context, link_text):
context.browser.find_element_by_link_text(link_text)
@when('I click the link to "{link_text}"')
def click_link(context, link_text):
context.browser.find_element_by_link_text(link_text).click()
----
====
Notice we can even use our `@wait` decorator on steps themselves.
And finally the slightly more complex step that says I am on the
page for a particular list:
[role="sourcecode"]
.features/steps/my_lists.py (ch35l009)
====
[source,python]
----
@then('I will be on the "{first_item_text}" list page')
@wait
def on_list_page(context, first_item_text):
first_row = context.browser.find_element_by_css_selector(
'#id_list_table tr:first-child'
)
expected_row_text = '1: ' + first_item_text
context.test.assertEqual(first_row.text, expected_row_text)
----
====
[role="pagebreak-before"]
Now we can run it and see our first expected failure:
[role="small-code"]
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py behave*]
Feature: My Lists # features/my_lists.feature:1
As a logged-in user
I want to be able to see all my lists in one page
So that I can find them all after I've written them
Scenario: Create two lists and see them on the My Lists page #
features/my_lists.feature:6
Given I am a logged-in user #
features/steps/my_lists.py:19
When I create a list with first item "Reticulate Splines" #
features/steps/my_lists.py:31
And I add an item "Immanentize Eschaton" #
features/steps/my_lists.py:39
And I create a list with first item "Buy milk" #
features/steps/my_lists.py:31
Then I will see a link to "My lists" #
functional_tests/base.py:12
Traceback (most recent call last):
[...]
File "features/steps/my_lists.py", line 49, in see_a_link
context.browser.find_element_by_link_text(link_text)
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to
locate element: My lists
[...]
Failing scenarios:
features/my_lists.feature:6 Create two lists and see them on the My Lists
page
0 features passed, 1 failed, 0 skipped
0 scenarios passed, 1 failed, 0 skipped
4 steps passed, 1 failed, 5 skipped, 0 undefined
----
You can see how the output really gives you a sense of how far through the
"story" of the test we got: we manage to create our two lists successfully, but
the "My lists" link does not appear.
=== Comparing the Inline-Style FT
((("behavior-driven development (BDD)", "comparing inline-style FT")))
I'm not going to run through the implementation of the feature,
but you can see how the test will drive development
just as well as the inline-style FT would have.
Let's have a look at it, for comparison:
[role="sourcecode skipme"]
.functional_tests/test_my_lists.py
====
[source,python]
----
def test_logged_in_users_lists_are_saved_as_my_lists(self):
# Edith is a logged-in user
self.create_pre_authenticated_session('edith@example.com')
# She goes to the homepage and starts a list
self.browser.get(self.live_server_url)
self.add_list_item('Reticulate splines')
self.add_list_item('Immanentize eschaton')
first_list_url = self.browser.current_url
# She notices a "My lists" link, for the first time.
self.browser.find_element_by_link_text('My lists').click()
# She sees that her list is in there, named according to its
# first list item
self.wait_for(
lambda: self.browser.find_element_by_link_text('Reticulate splines')
)
self.browser.find_element_by_link_text('Reticulate splines').click()
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, first_list_url)
)
# She decides to start another list, just to see
self.browser.get(self.live_server_url)
self.add_list_item('Click cows')
second_list_url = self.browser.current_url
# Under "my lists", her new list appears
self.browser.find_element_by_link_text('My lists').click()
self.wait_for(
lambda: self.browser.find_element_by_link_text('Click cows')
)
self.browser.find_element_by_link_text('Click cows').click()
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, second_list_url)
)
# She logs out. The "My lists" option disappears
self.browser.find_element_by_link_text('Log out').click()
self.wait_for(lambda: self.assertEqual(
self.browser.find_elements_by_link_text('My lists'),
[]
))
----
====
It's not entirely an apples-to-apples comparison, but we can look at the
number of lines of code in <<table-code-compare>>.
[[table-code-compare]]
.Lines of code comparison
[options="header"]
|==============================================================================
|BDD |Standard FT
|Feature file: 20 (3 optional) |test function body: 45
|Steps file: 56 lines |helper functions: 23
|==============================================================================
The comparison isn't perfect, but you might say that the feature file and the
body of a "standard FT" test function are equivalent in that they present the
main "story" of a test, while the steps and helper functions represent the
"hidden" implementation details. If you add them up, the total numbers are
pretty similar, but notice that they're spread out differently: the BDD tests
have made the story more concise, and pushed more work out into the hidden
implementation details.
=== BDD Encourages Structured Test Code
((("behavior-driven development (BDD)", "structured test code encouraged by")))
((("functional tests (FTs)", "structuring test code")))
This is the real appeal, for me: the BDD tool has _forced_ us to structure our
test code. In the inline-style FT, we're free to use as many lines as we want
to implement a step, as described by its comment line. It's very hard to
resist the urge to just copy-and-paste code from elsewhere, or just from
earlier on in the test. You can see that, by this point in the book, I've
built just a couple of helper functions (like `get_item_input_box`).
In contrast, the BDD syntax has immediately forced me to have a separate
function for each step, so I've already built some very reusable code to:
* Start a new list
* Add an item to an existing list
* Click on a link with particular text
* Assert that I'm looking at a particular list's page
BDD really encourages you to write test code that seems to match well with
the business domain, and to use a layer of abstraction between the story of
your FT and its implementation in code.
The ultimate expression of this is that, theoretically, if you wanted to
change programming languages, you could keep all your features in Gherkin
syntax exactly as they are, and throw away the Python steps and replace them
with steps implemented in another language.
=== The Page Pattern as an Alternative
((("behavior-driven development (BDD)", "page pattern")))
((("page pattern")))
In <<chapter_26_page_pattern>> of the book,
I present an example of the "Page pattern",
which is an object-oriented approach to structuring your Selenium tests.
Here's a reminder of what it looks like:
[role="sourcecode skipme"]
.functional_tests/test_sharing.py
====
[source,python]
----
from .my_lists_page import MyListsPage
[...]
class SharingTest(FunctionalTest):
def test_can_share_a_list_with_another_user(self):
# [...]
self.browser.get(self.live_server_url)
list_page = ListPage(self).add_list_item('Get help')
# She notices a "Share this list" option
share_box = list_page.get_share_box()
self.assertEqual(
share_box.get_attribute('placeholder'),
'your-friend@example.com'
)
# She shares her list.
# The page updates to say that it's shared with Oniciferous:
list_page.share_list_with('oniciferous@example.com')
----
====
//TODO: all these skipmes could be tested by doing a checkout of the page_pattern branch
And the +Page+ class looks like this:
[role="sourcecode small-code skipme"]
.functional_tests/lists_pages.py
====
[source,python]
----
class ListPage(object):
def __init__(self, test):
self.test = test
def get_table_rows(self):
return self.test.browser.find_elements_by_css_selector('#id_list_table tr')
@wait
def wait_for_row_in_list_table(self, item_text, item_number):
row_text = '{}: {}'.format(item_number, item_text)
rows = self.get_table_rows()
self.test.assertIn(row_text, [row.text for row in rows])
def get_item_input_box(self):
return self.test.browser.find_element(By.ID, 'id_text')
----
====
So it's definitely possible to implement a similar layer of abstraction,
and a sort of DSL, in inline-style FTs, whether it's by using the Page
pattern or whatever structure you prefer--but now it's a matter of
self-discipline, rather than having a framework that pushes you towards
it.
NOTE: In fact, you can actually use the Page pattern with BDD as well, as
a resource for your steps to use when navigating the pages of your site.
=== BDD Might Be Less Expressive than Inline Comments
((("behavior-driven development (BDD)", "vs. inline comments", secondary-sortas="inline comments")))
((("inline comments")))
On the other hand, I can also see potential for the Gherkin syntax to
feel somewhat restrictive. Compare how expressive and readable the
inline-style comments are, with the slightly awkward BDD feature:
[role="sourcecode skipme"]
.functional_tests/test_my_lists.py
====
[source,python]
----
# Edith is a logged-in user
# She goes to the homepage and starts a list
# She notices a "My lists" link, for the first time.
# She sees that her list is in there, named according to its
# first list item
# She decides to start another list, just to see
# Under "my lists", her new list appears
# She logs out. The "My lists" option disappears
[...]
----
====
That's much more readable and natural than our slightly forced Given/Then/When
incantations, and, in a way, might encourage more user-centric thinking. (There
is a syntax in Gherkin for including "comments" in a feature file, which would
mitigate this somewhat, but I gather that it's not widely used.)
=== Will Nonprogrammers Write Tests?
((("behavior-driven development (BDD)", "benefits and drawbacks of")))I
haven't touched on one of the original promises of BDD, which is that
nonprogrammers--business or client representatives perhaps--might actually
write the Gherkin syntax. I'm quite skeptical about whether this would
actually work in the real world, but I don't think that detracts from the other
potential benefits of BDD.
=== Some Tentative Conclusions
I've only dipped my toes into the BDD world, so I'm hesitant to draw any firm
conclusions. I find the "forced" structuring of FTs into steps very appealing
though--in that it looks like it has the potential to encourage a lot of reuse in your
FT code, and that it neatly separates concerns between describing the story
and implementing it, and that it forces us to think about things in terms of
the business domain, rather than in terms of "what we need to do with
Selenium."
But there's no free lunch. The Gherkin syntax is restrictive, compared to
the total freedom offered by inline FT comments.
I also would like to see how BDD scales once you have not just one or two
features, and four or five steps, but several dozen features and hundreds of
lines of steps code.
Overall, I would say it's definitely worth investigating, and I will probably
use BDD for my next personal project.
My thanks to Daniel Pope, Rachel Willmer, and Jared Contrascere for their
feedback on this chapter.
.BDD Conclusions
*******************************************************************************
Encourages structured, reusable test code::
By separating concerns, breaking your FTs out into the human-readable,
Gherkin syntax "feature" file and a separate implementation of steps
functions, BDD has the potential to encourage more reusable and manageable
test code.
It may come at the expense of readability::
The Gherkin syntax, for all its attempt to be human-readable, is ultimately
a constraint on human language, and so it may not capture nuance and
intention as well as inline comments do.
Try it! I will::
As I keep saying, I haven't used BDD on a real project, so you should take
my words with a heavy pinch of salt, but I'd like to give it a hearty
endorsement. I'm going to try it out on the next project I can, and I'd
encourage you to do so as well.((("", startref="bdd31")))
*******************************************************************************
================================================
FILE: appendix_fts_for_external_dependencies.asciidoc
================================================
[[appendix_fts_for_external_dependencies]]
[appendix]
== The Subtleties of Functionally Testing External Dependencies
You might remember from <<options-for-testing-real-email>>
a point at which we wanted to test sending email from the server.
Here were the options we considered:
1. We could build a "real" end-to-end test, and have our tests
log in to an email server, and retrieve the email from there.
That's what I did in the first and second edition.
2. You can use a service like Mailinator or Mailsac,
which give you an email account to send to,
and some APIs for checking what mail has been delivered.
3. We can use an alternative, fake email backend,
whereby Django will save the emails to a file on disk for example,
and we can inspect them there.
4. Or we could give up on testing email on the server.
If we have a minimal smoke test that the server _can_ send emails,
then we don't need to test that they are _actually_ delivered.
In the end we decided not to bother,
but let's spend a bit of time in this appendix trying out options 1 and 3,
just to see some of the fiddliness and trade-offs involved.
=== How to Test Email End-To-End with POP3
Here's an example helper function that can retrieve a real email
from a real POP3 email server,
using the horrifically tortuous Python standard library POP3 client.
To make it work, we'll need an email address to receive the email.
I signed up for a Yahoo account for testing,
but you can use any email service you like, as long as it offers POP3 access.
You will need to set the
`RECEIVER_EMAIL_PASSWORD` environment variable in the console that's running the FT.
[subs="specialcharacters,quotes"]
----
$ *export RECEIVER_EMAIL_PASSWORD=otheremailpasswordhere*
----
[role="sourcecode skipme"]
.src/functional_tests/test_login.py (ch23l001)
====
[source,python]
----
import os
import poplib
import re
impot time
[...]
def retrieve_pop3_email(receiver_email, subject, pop3_server, pop3_password):
email_id = None
start = time.time()
inbox = poplib.POP3_SSL(pop3_server)
try:
inbox.user(receiver_email)
inbox.pass_(pop3_password)
while time.time() - start < POP3_TIMEOUT:
# get 10 newest messages
count, _ = inbox.stat()
for i in reversed(range(max(1, count - 10), count + 1)):
print("getting msg", i)
_, lines, __ = inbox.retr(i)
lines = [l.decode("utf8") for l in lines]
print(lines)
if f"Subject: {subject}" in lines:
email_id = i
body = "\n".join(lines)
return body
time.sleep(5)
finally:
if email_id:
inbox.dele(email_id)
inbox.quit()
----
====
If you're curious, I'd encourage you to try this out in your FTs.
It definitely _can_ work.
But, having tried it in the first couple of editions of the book.
I have to say it's fiddly to get right,
and often flaky, which is a highly undesirable property for a testing tool.
So let's leave that there for now.
=== Using a Fake Email Backend For Django
Next let's investigate using a filesystem-based email backend.
As we'll see, although it definitely has the advantage
that everything stays local on our own machine
(there are no calls over the internet),
there are quite a few things to watch out for.
Let's say that, if we detect an environment variable `EMAIL_FILE_PATH`,
we switch to Django's file-based backend:
.src/superlists/settings.py (ch23l002)
====
[source,python]
----
EMAIL_HOST = "smtp.gmail.com"
EMAIL_HOST_USER = "obeythetestinggoat@gmail.com"
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_PASSWORD")
EMAIL_PORT = 587
EMAIL_USE_TLS = True
# Use fake file-based backend if EMAIL_FILE_PATH is set
if "EMAIL_FILE_PATH" in os.environ:
EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = os.environ["EMAIL_FILE_PATH"]
----
====
Here's how we can adapt our tests to conditionally use the email file,
instead of Django's `mail.outbox`, if the env var is set when running our tests:
[role="sourcecode"]
.src/functional_tests/test_login.py (ch23l003)
====
[source,python]
----
class LoginTest(FunctionalTest):
def retrieve_email_from_file(self, sent_to, subject, emails_dir): # <1>
latest_emails_file = sorted(Path(emails_dir).iterdir())[-1] # <2>
latest_email = latest_emails_file.read_text().split("-" * 80)[-1] # <3>
self.assertIn(subject, latest_email)
self.assertIn(sent_to, latest_email)
return latest_email
def retrieve_email_from_django_outbox(self, sent_to, subject): # <4>
email = mail.outbox.pop()
self.assertIn(sent_to, email.to)
self.assertEqual(email.subject, subject)
return email.body
def wait_for_email(self, sent_to, subject): # <5>
"""
Retrieve email body,
from a file if the right env var is set,
or get it from django.mail.outbox by default
"""
if email_file_path := os.environ.get("EMAIL_FILE_PATH"): # <6>
return self.wait_for( # <7>
lambda: self.retrieve_email_from_file(sent_to, subject, email_file_path)
)
else:
return self.retrieve_email_from_django_outbox(sent_to, subject)
def test_login_using_magic_link(self):
[...]
----
====
<1> Here's our helper method for getting email contents from a file.
It takes the configured email directory as an argument,
as well as the sent-to address and expected subject.
<2> Django saves a new file with emails every time you restart the server.
The filename has a timestamp in it,
so we can get the latest one by sorting the files in our test directory.
Check out the https://docs.python.org/3/library/pathlib.html[Pathlib] docs
if you haven't used it before, it's a nice, relatively new way of working with files in Python.
<3> The emails in the file are separated by a line of 80 hyphens.
<4> This is the matching helper for getting the email from `mail.outbox`.
<5> Here's where we dispatch to the right helper based on whether the env
var is set.
<6> Checking whether an environment variable is set, and using its value if so,
is one of the (relatively few) places where it's nice to use the walrus operator.
<7> I'm using a `wait_for()` here because anything involving reading and writing from files,
especially across the filesystem mounts inside and outside of Docker,
has a potential race condition.
We'll need a couple more minor changes to the FT, to use the helper:
[role="sourcecode"]
.src/functional_tests/test_login.py (ch23l004)
====
[source,diff]
----
@@ -59,15 +59,12 @@ class LoginTest(FunctionalTest):
)
# She checks her email and finds a message
- email = mail.outbox.pop()
- self.assertIn(TEST_EMAIL, email.to)
- self.assertEqual(email.subject, SUBJECT)
+ email_body = self.wait_for_email(TEST_EMAIL, SUBJECT)
# It has a URL link in it
- self.assertIn("Use this link to log in", email.body)
- url_search = re.search(r"http://.+/.+$", email.body)
- if not url_search:
- self.fail(f"Could not find url in email body:\n{email.body}")
+ self.assertIn("Use this link to log in", email_body)
+ if not (url_search := re.search(r"http://.+/.+$", email_body, re.MULTILINE)):
+ self.fail(f"Could not find url in email body:\n{email_body}")
url = url_search.group(0)
self.assertIn(self.live_server_url, url)
----
====
// TODO backport that walrus
Now let's set that file path, and mount it inside our docker container,
so that it's available both inside and outside the container:
[subs="attributes+,specialcharacters,quotes"]
----
# set a local env var for our path to the emails file
$ *export EMAIL_FILE_PATH=/tmp/superlists-emails*
# make sure the file exists
$ *mkdir -p $EMAIL_FILE_PATH*
# re-run our container, with the EMAIL_FILE_PATH as an env var, and mounted.
$ *docker build -t superlists . && docker run \
-p 8888:8888 \
--mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \
--mount type=bind,source=$EMAIL_FILE_PATH,target=$EMAIL_FILE_PATH \ <1>
-e DJANGO_SECRET_KEY=sekrit \
-e DJANGO_ALLOWED_HOST=localhost \
-e EMAIL_PASSWORD \
-e EMAIL_FILE_PATH \ <2>
-it superlists*
----
<1> Here's where we mount the emails file so we can see it
both inside and outside the container
<2> And here's where we pass the path as an env var,
once again re-exporting the variable from the current shell.
And we can rerun our FT, first without using Docker or the EMAIL_FILE_PATH,
just to check we didn't break anything:
[subs="specialcharacters,macros"]
----
$ pass:quotes[*./src/manage.py test functional_tests.test_login*]
[...]
OK
----
And now _with_ Docker and the EMAIL_FILE_PATH:
[subs="specialcharacters,quotes"]
----
$ *TEST_SERVER=localhost:8888 EMAIL_FILE_PATH=/tmp/superlists-emails \
python src/manage.py test functional_tests*
[...]
OK
----
It works! Hooray.
=== Double-Checking our Test and Our Fix
As always, we should be suspicious of any test that we've only ever seen pass!
Let's see if we can make this test fail.
Before we do--we've been in the detail for a bit,
it's worth reminding ourselves of what the actual bug was,
and how we're fixing it!
The bug was, the server was crashing when it tried to send an email.
The reason was, we hadn't set the `EMAIL_PASSWORD` environment variable.
We managed to repro the bug in Docker.
The actual _fix_ is to set that env var,
both in Docker and eventually on the server.
Now we want to have a _test_ that our fix works,
and we looked in to a few different options,
settling on using the `filebased.EmailBackend"
`EMAIL_BACKEND` setting using the `EMAIL_FILE_PATH` environment variable.
Now, I say we haven't seen the test fail,
but actually we have, when we repro'd the bug.
If we unset the `EMAIL_PASSWORD` env var, it will fail again.
I'm more worried about the new parts of our tests,
the bits where we go and read from the file at `EMAIL_FILE_PATH`.
How can we make that part fail?
Well, how about if we deliberately break our email-sending code?
[role="sourcecode"]
.src/accounts/views.py (ch23l005)
====
[source,python]
----
def send_login_email(request):
email = request.POST["email"]
token = Token.objects.create(email=email)
url = request.build_absolute_uri(
reverse("login") + "?token=" + str(token.uid),
)
message_body = f"Use this link to log in:\n\n{url}"
# send_mail( <1>
# "Your login link for Superlists",
# message_body,
# "noreply@superlists",
# [email],
# )
messages.success(
request,
"Check your email, we've sent you a link you can use to log in.",
)
return redirect("/")
----
====
<1> We just comment out the entire send_email block.
We rebuild our docker image:
[subs="specialcharacters,quotes"]
----
# check our env var is set
$ *echo $EMAIL_FILE_PATH*
/tmp/superlists-emails
$ *docker build -t superlists . && docker run \
-p 8888:8888 \
--mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \
--mount type=bind,source=$EMAIL_FILE_PATH,target=$EMAIL_FILE_PATH \
-e DJANGO_SECRET_KEY=sekrit \
-e DJANGO_ALLOWED_HOST=localhost \
-e EMAIL_PASSWORD \
-e EMAIL_FILE_PATH \
-it superlists*
----
// TODO: aside on moujnting /src/?
And we rerun our test:
[subs="specialcharacters,quotes"]
----
$ *TEST_SERVER=localhost:8888 EMAIL_FILE_PATH=/tmp/superlists-emails \
./src/manage.py test functional_tests.test_login
[...]
Ran 1 test in 2.513s
OK
----
Eh? How did that pass?
=== Testing side-effects is fiddly!
We've run into an example of the kinds of problems you often encounter
when our tests involve side-effects.
Let's have a look in our test emails directory:
[role="skipme"]
[subs="specialcharacters,quotes"]
----
$ *ls $EMAIL_FILE_PATH*
20241120-153150-262004991022080.log
20241120-153154-262004990980688.log
20241120-153301-272143941669888.log
----
Every time we restart the server, it opens a new file,
but only when it first tries to send an email.
Because we've commented out the whole email-sending block,
our test instead picks up on an old email,
which still has a valid url in it,
because the token is still in the database.
NOTE: You'll run into a similar issue if you test with "real" emails in POP3.
How do you make sure you're not picking up an email from a previous test run?
Let's clear out the db:
[subs="specialcharacters,quotes"]
----
$ *rm src/db.sqlite3 && ./src/manage.py migrate*
Operations to perform:
Apply all migrations: accounts, auth, contenttypes, lists, sessions
Running migrations:
Applying accounts.0001_initial... OK
Applying accounts.0002_token... OK
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
----
And...
cmdgg
[subs="specialcharacters,quotes"]
----
$ *TEST_SERVER=localhost:8888 ./src/manage.py test functional_tests.test_login*
[...]
ERROR: test_login_using_magic_link (functional_tests.test_login.LoginTest.test_login_using_magic_link)
self.wait_to_be_logged_in(email=TEST_EMAIL)
~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
[...]
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: #id_logout; [...]
----
OK sure enough, the `wait_to_be_logged_in()` helper is failing,
because now, although we have found an email, its token is invalid.
Here's another way to make the tests fail:
[subs="specialcharacters,macros"]
----
$ pass:[<strong>rm $EMAIL_FILE_PATH/*</strong>]
----
Now when we run the FT:
[subs="specialcharacters,quotes"]
----
$ *TEST_SERVER=localhost:8888 ./src/manage.py test functional_tests.test_login*
ERROR: test_login_using_magic_link
(functional_tests.test_login.LoginTest.test_login_using_magic_link)
[...]
email_body = self.wait_for_email(TEST_EMAIL, SUBJECT)
[...]
return self.wait_for(
~~~~~~~~~~~~~^
lambda: self.retrieve_email_from_file(sent_to, subject, email_file_path)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[...]
latest_emails_file = sorted(Path(emails_dir).iterdir())[-1]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^
IndexError: list index out of range
----
We see there are no email files, because we're not sending one.
NOTE: In this configuration of Docker + `filebase.EmailBackend`,
we now have to manage side effects in two locations:
the database at _src/db.sqlite3_, and the email files in _/tmp_.
What Django used to do for us thanks to LiveServerTestCase
is now all our responsibility, and as you can see, it's hard to get right.
This is a tradeoff to be aware of when writing tests against "real" systems.
Still, this isn't quite satisfactory.
Let's try a different way to make our tests fail,
where we _will_ send an email, but we'll give it the wrong contents:
[role="sourcecode"]
.src/accounts/views.py (ch23l006)
====
[source,python]
----
def send_login_email(request):
email = request.POST["email"]
token = Token.objects.create(email=email)
url = request.build_absolute_uri(
reverse("login") + "?token=" + str(token.uid),
)
message_body = f"Use this link to log in:\n\n{url}"
send_mail(
"Your login link for Superlists",
"HAHA NO LOGIN URL FOR U", # <1>
"noreply@superlists",
[email],
)
messages.success(
request,
"Check your email, we've sent you a link you can use to log in.",
)
return redirect("/")
----
====
<1> We _do_ send an email, but it won't contain a login URL.
Let's rebuild again:
[subs="specialcharacters,quotes"]
----
# check our env var is set
$ *echo $EMAIL_FILE_PATH*
/tmp/superlists-emails
$ *docker build -t superlists . && docker run \
-p 8888:8888 \
--mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \
--mount type=bind,source=$EMAIL_FILE_PATH,target=$EMAIL_FILE_PATH \
-e DJANGO_SECRET_KEY=sekrit \
-e DJANGO_ALLOWED_HOST=localhost \
-e EMAIL_PASSWORD \
-e EMAIL_FILE_PATH \
-it superlists*
----
Now how do our tests look?
[subs="specialcharacters,macros"]
----
$ pass:quotes[*TEST_SERVER=localhost:8888 python src/manage.py test functional_tests*]
FAIL: test_login_using_magic_link
(functional_tests.test_login.LoginTest.test_login_using_magic_link)
[...]
email_body = self.wait_for_email(TEST_EMAIL, SUBJECT)
[...]
self.assertIn("Use this link to log in", email_body)
~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 'Use this link to log in' not found in 'Content-Type:
text/plain; charset="utf-8"\nMIME-Version: 1.0\nContent-Transfer-Encoding:
7bit\nSubject: Your login link for Superlists\nFrom: noreply@superlists\nTo:
edith@example.com\nDate: Wed, 13 Nov 2024 18:00:55 -0000\nMessage-ID:
[...]\n\nHAHA NO LOGIN URL FOR
U\n-------------------------------------------------------------------------------\n'
----
OK good, that's the error we wanted!
I think we can be fairly confident that this testing setup
can genuinely test that emails are sent properly.
Let's revert our temporarily-broken _views.py_,
rebuild, and make sure the tests pass once again.
[subs="specialcharacters,quotes"]
----
$ *git stash*
$ *docker build [...]*
# separate terminal
$ *TEST_SERVER=localhost:8888 EMAIL_FILE_PATH=/tmp/superlists-emails [...]
[...]
OK
----
NOTE: It may seem like I've gone through a lot of back-and-forth,
but I wanted to give you a flavour of the fiddliness involved
in these kinds of tests that involve a lot of side-effects.
=== Decision Time: Which Test Strategy Will We Keep
Let's recap our three options:
.Testing Strategy Tradeoffs
[cols="1,1,1"]
|=======
| Strategy | Pros | Cons
| End-to-end with POP3 | Maximally realistic, tests the whole system | Slow, fiddly, unreliable
| File-based fake email backend | Faster, more reliable, no network calls, tests end-to-end (albeit with fake components) | Still Fiddly, requires managing db & filesystem side-effects
| Give up on testing email on the server/Docker | Fast, simple | Less confidence that things work "for real"
|=======
This is a common problem in testing integration with external systems,
how far should we go? How realistic should we make our tests?
In the book in the end, I suggested we go for the last option,
ie give up. Email itself is a well-understood protocol
(reader, it's been around since _before I was born_, and that's a whiles ago now)
and Django has supported sending email for more than a decade,
so I think we can afford to say, in this case,
that the costs of building testing tools for email outweigh the benefits.
But not all external dependencies are as well-understood as email.
If you're working with a new API, or a new service,
you may well decide it's worth putting in the effort to get a "real" end-to-end functional test to work.
As always, it's tradeoffs all the way down, folks.
================================================
FILE: appendix_github_links.asciidoc
================================================
[[appendix_github_links]]
[appendix]
== Source Code Examples
((("code examples, obtaining and using")))
All
of the code examples I've used in the book are available in
https://github.com/hjwp/book-example/[my repo on GitHub].
So, if you ever want to compare your code against mine,
you can take a look at it there.
Each chapter has its own branch named after it, like so:
https://github.com/hjwp/book-example/tree/chapter_01.
Be aware that each branch contains all of the commits for that chapter,
so its state represents the code at the 'end' of the chapter.
=== Full List of Links for Each Chapter
|===
| Chapter | GitHub branch name & hyperlink
| <<chapter_01>>
| https://github.com/hjwp/book-example/tree/chapter_01[chapter_01]
| <<chapter_02_unittest>>
| https://github.com/hjwp/book-example/tree/chapter_02_unittest[chapter_02_unittest]
| <<chapter_03_unit_test_first_view>>
| https://github.com/hjwp/book-example/tree/chapter_03_unit_test_first_view[chapter_03_unit_test_first_view]
| <<chapter_04_philosophy_and_refactoring>>
| https://github.com/hjwp/book-example/tree/chapter_04_philosophy_and_refactoring[chapter_04_philosophy_and_refactoring]
| <<chapter_05_post_and_database>>
| https://github.com/hjwp/book-example/tree/chapter_05_post_and_database[chapter_05_post_and_database]
| <<chapter_06_explicit_waits_1>>
| https://github.com/hjwp/book-example/tree/chapter_06_explicit_waits_1[chapter_06_explicit_waits_1]
| <<chapter_07_working_incrementally>>
| https://github.com/hjwp/book-example/tree/chapter_07_working_incrementally[chapter_07_working_incrementally]
| <<chapter_08_prettification>>
| https://github.com/hjwp/book-example/tree/chapter_08_prettification[chapter_08_prettification]
| <<chapter_09_docker>>
| https://github.com/hjwp/book-example/tree/chapter_09_docker[chapter_09_docker]
| <<chapter_10_production_readiness>>
| https://github.com/hjwp/book-example/tree/chapter_10_production_readiness[chapter_10_production_readiness]
| <<chapter_11_server_prep>>
| https://github.com/hjwp/book-example/tree/chapter_11_server_prep[chapter_11_server_prep]
| <<chapter_13_organising_test_files>>
| https://github.com/hjwp/book-example/tree/chapter_13_organising_test_files[chapter_13_organising_test_files]
| <<chapter_14_database_layer_validation>>
| https://github.com/hjwp/book-example/tree/chapter_14_database_layer_validation[chapter_14_database_layer_validation]
| <<chapter_15_simple_form>>
| https://github.com/hjwp/book-example/tree/chapter_15_simple_form[chapter_15_simple_form]
| <<chapter_16_advanced_forms>>
| https://github.com/hjwp/book-example/tree/chapter_16_advanced_forms[chapter_16_advanced_forms]
| <<chapter_17_javascript>>
| https://github.com/hjwp/book-example/tree/chapter_17_javascript[chapter_17_javascript]
| <<chapter_18_second_deploy>>
| https://github.com/hjwp/book-example/tree/chapter_18_second_deploy[chapter_18_second_deploy]
| <<chapter_19_spiking_custom_auth>>
| https://github.com/hjwp/book-example/tree/chapter_19_spiking_custom_auth[chapter_19_spiking_custom_auth]
| <<chapter_20_mocking_1>>
| https://github.com/hjwp/book-example/tree/chapter_20_mocking_1[chapter_20_mocking_1]
| <<chapter_21_mocking_2>>
| https://github.com/hjwp/book-example/tree/chapter_21_mocking_2[chapter_21_mocking_2]
| <<chapter_22_fixtures_and_wait_decorator>>
| https://github.com/hjwp/book-example/tree/chapter_22_fixtures_and_wait_decorator[chapter_22_fixtures_and_wait_decorator]
| <<chapter_23_debugging_prod>>
| https://github.com/hjwp/book-example/tree/chapter_23_debugging_prod[chapter_23_debugging_prod]
| <<chapter_24_outside_in>>
| https://github.com/hjwp/book-example/tree/chapter_24_outside_in[chapter_24_outside_in]
| <<chapter_25_CI>>
| https://github.com/hjwp/book-example/tree/chapter_25_CI[chapter_25_CI]
| <<chapter_26_page_pattern>>
| https://github.com/hjwp/book-example/tree/chapter_26_page_pattern[chapter_26_page_pattern]
| Online Appendix: Test Isolation, and Listening to Your Tests
| https://github.com/hjwp/book-example/tree/appendix_purist_unit_tests[appendix_purist_unit_tests]
| Online Appendix: BDD
| https://github.com/hjwp/book-example/tree/appendix_bdd[appendix_bdd]
| Online Apendix: Building a REST API
| https://github.com/hjwp/book-example/tree/appendix_rest_api[appendix_rest_api]
|===
=== Using Git to Check Your Progress
If you feel like developing your Git-Fu a little further, you can add
my repo as a 'remote':
[role="skipme"]
-----
git remote add harry https://github.com/hjwp/book-example.git
git fetch harry
-----
And then, to check your difference from the 'end' of <<chapter_04_philosophy_and_refactoring>>:
[role="skipme"]
----
git diff harry/chapter_04_philosophy_and_refactoring
----
Git can handle multiple remotes, so you can still do this even if you're
already pushing your code up to GitHub or Bitbucket.
Be aware that the precise order of, say, methods in a class may differ
between your version and mine. It may make diffs hard to read.
=== Downloading a ZIP File for a Chapter
If, for whatever reason, you want to "start from scratch" for a chapter,
or skip ahead,footnote:[
I don't recommend skipping ahead.
I haven't designed the chapters to stand on their own;
each relies on the previous ones, so it may be more confusing than anything else...]
and/or you're just not comfortable with Git,
you can download a version of my code as a ZIP file,
from URLs following this pattern:
https://github.com/hjwp/book-example/archive/chapter_01.zip
https://github.com/hjwp/book-example/archive/chapter_04_philosophy_and_refactoring.zip
=== Don't Let it Become a Crutch!
Try not to sneak a peek at the answers unless you're really, really stuck.
Like I said at the beginning of <<chapter_03_unit_test_first_view>>,
there's a lot of value in debugging errors all by yourself,
and in real life, there's no "harrys repo" to check against
and find all the answers.
Happy coding!
================================================
FILE: appendix_logging.asciidoc
================================================
[[appendix_logging]]
[apendix]
Logging
~~~~~~~
Using Hierarchical Logging Config
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
NOTE: this content is left over from the first edition, and has not been integrated
into the new edition
When we hacked in the `logging.warning` earlier, we were using the root logger.
That's not normally a good idea, since any third-party package can mess with the
root logger. The normal pattern is to use a logger named after the file you're
in, by using:
[role="skipme"]
[source,python]
----
logger = logging.getLogger(__name__)
----
Logging configuration is hierarchical, so you can define "parent" loggers for
top-level modules, and all the Python modules inside them will inherit that
config.
Here's how we add a logger for both our apps into 'settings.py':
[role="sourcecode skipme"]
.superlists/settings.py
====
[source,python]
----
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django': {
'handlers': ['console'],
},
'accounts': {
'handlers': ['console'],
},
'lists': {
'handlers': ['console'],
},
},
'root': {'level': 'INFO'},
}
----
====
Now 'accounts.models', 'accounts.views', 'accounts.authentication', and all
the others will inherit the `logging.StreamHandler` from the parent 'accounts'
logger.
Unfortunately, because of Django's project structure, there's no
way of defining a top-level logger for your whole project (aside from using
the root logger), so you have to define one logger per app.
Here's how to write a test for logging behaviour:
[role="sourcecode skipme"]
.accounts/tests/test_authentication.py (ch18l023)
====
[source,python]
----
import logging
[...]
@patch('accounts.authentication.requests.post')
class AuthenticateTest(TestCase):
[...]
def test_logs_non_okay_responses_from_persona(self, mock_post):
response_json = {
'status': 'not okay', 'reason': 'eg, audience mismatch'
}
mock_post.return_value.ok = True
mock_post.return_value.json.return_value = response_json #<1>
logger = logging.getLogger('accounts.authentication') #<2>
with patch.object(logger, 'warning') as mock_log_warning: #<3>
self.backend.authenticate('an assertion')
mock_log_warning.assert_called_once_with(
'Persona says no. Json was: {}'.format(response_json) #<4>
)
----
====
<1> We set up our test with some data that should cause some logging.
<2> We retrieve the actual logger for the module we're testing.
<3> We use `patch.object` to temporarily mock out its warning function,
by using `with` to make it a 'context manager' around the function we're
testing.
<4> And then it's available for us to make assertions against.
That gives us:
[role="skipme"]
[subs="specialcharacters,macros"]
----
AssertionError: Expected 'warning' to be called once. Called 0 times.
----
Let's just try it out, to make sure we really are testing what we think
we are:
[role="sourcecode skipme"]
.accounts/authentication.py (ch18l024)
====
[source,python]
----
import logging
logger = logging.getLogger(__name__)
[...]
if response.ok and response.json()['status'] == 'okay':
[...]
else:
logger.warning('foo')
----
====
We get the expected failure:
[role="skipme"]
[subs="specialcharacters,macros"]
----
AssertionError: Expected call: warning("Persona says no. Json was: {'status':
'not okay', 'reason': 'eg, audience mismatch'}")
Actual call: warning('foo')
----
And so we settle in with our real implementation:
[role="sourcecode skipme"]
.accounts/authentication.py (ch18l025)
====
[source,python]
----
else:
logger.warning(
'Persona says no. Json was: {}'.format(response.json())
)
----
====
[role="skipme"]
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test accounts*]
[...]
Ran 15 tests in 0.033s
OK
----
You can easily imagine how you could test more combinations at this point,
if you wanted different error messages for `response.ok != True`, and so on.
.More notes
*******************************************************************************
Use loggers named after the module you're in::
The root logger is a single global object, available to any library that's
loaded in your Python process, so you're never quite in control of it.
Instead, follow the `logging.getLogger(__name__)` pattern to get one that's
unique to your module, but that inherits from a top-level configuration you
control.
Test important log messages::
As we saw, log messages can be critical to debugging issues in production.
If a log message is important enough to keep in your codebase, it's
probably important enough to test. We follow the rule of thumb that
anything above `logging.INFO` definitely needs a test. Using
`patch.object` on the logger for the module you're testing is one
convenient way of unit testing it.
*******************************************************************************
================================================
FILE: appendix_purist_unit_tests.asciidoc
================================================
[[appendix_purist_unit_tests]]
[appendix]
== Test Isolation, and "Listening to Your Tests"
.Warning, Appendix Not Updated
*******************************************************************************
🚧 Warning, this appendix is the 2e version, and uses Django 1.11
This appendix and all the following ones are the second edition versions, so they still use Django 1.11, Python 3.8, and so on.
To follow along with this appendix, it’s probably easiest to reset your code to match my example code as it was in the 2e, by resetting to: https://github.com/hjwp/book-example/tree/chapter_outside_in
And you should also probably delete and re-create your virtualenv with
* Python 3.8 or 3.9
* and Django 1.11 (pip install "django <2")
Alternatively, you can muddle through
and try and figure out how to make things work with Django 5 etc,
but be aware that the listings below won’t be quite right.
*******************************************************************************
((("functional tests (FTs)", "ensuring isolation", id="FTisolat23")))
This appendix picks up from a point about half-way through <<chapter_24_outside_in>>,
when we made the decision to leave a unit test failing in the views layer
while we proceeded to write more tests and more code at the models layer
to get it to pass: <<revisit_this_point_with_isolated_tests>>.
We got away with it because our app is simple,
but in a more complex application,
this would feel more risky.
Is there a way to "finish" a higher level,
even when the lower levels don't exist yet?footnote:[
I'm grateful to Gary Bernhardt,
who took a look at an early draft of the <<chapter_24_outside_in>>,
and encouraged me to get into a longer discussion of test isolation.]
In this appendix we'll explore the using mocks
to stand in for parts of the code we haven't written yet,
enabling a form of outside-in development with isolated tests at each layer.
WARNING: This is an example of "London-school" TDD,
which is not the style I usually use,
which means I'm not necessarily the best guide to the topic.
If you're intrigued, the seminal book on the topic is
"GOOSGBT", aka
http://www.growing-object-oriented-software.com/[Growing Object-Oriented Software Guided by Tests]
by Steve Freeman and Nat Pryce,
and I enthusiastically recommend you read that
as a better guide to London-style TDD.
((("isolation, ensuring", "benefits and drawbacks of")))
As we'll see, using mocks in this way can be a lot of work,
but it can be a way to use our tests to give us feedback on design,
and thus encourage us to write better code.
NOTE: I revisited some of the tradeoffs outlined here in my
my https://www.cosmicpython.com[second book on architecture patterns].
=== Revisiting Our Decision Point: The Views Layer Depends on Unwritten Models Code
((("isolation, ensuring", "failed test example")))
Let's revisit the point we were at halfway through the outside-in chapter,
when we couldn't get the `new_list` view to work
because lists didn't have the `.owner` attribute yet.
We'll actually go back in time and check out the old codebase using the tag we
saved earlier, so that we can see how things would have worked if we'd used
more isolated tests:
[subs="specialcharacters,quotes"]
----
$ *git switch -c more-isolation* # a branch for this experiment
$ *git reset --hard revisit_this_point_with_isolated_tests*
----
Here's what our failing test looks like:
[role="sourcecode currentcontents"]
.lists/tests/test_views.py
====
[source,python]
----
class NewListTest(TestCase):
[...]
def test_list_owner_is_saved_if_user_is_authenticated(self):
user = User.objects.create(email='a@b.com')
self.client.force_login(user)
self.client.post('/lists/new', data={'text': 'new item'})
list_ = List.objects.first()
self.assertEqual(list_.owner, user)
----
====
And here's what our attempted solution looked like:
[role="sourcecode currentcontents"]
.lists/views.py
====
[source,python]
----
def new_list(request):
form = ItemForm(data=request.POST)
if form.is_valid():
list_ = List()
list_.owner = request.user
list_.save()
form.save(for_list=list_)
return redirect(list_)
else:
return render(request, 'home.html', {"form": form})
----
====
And at this point, the view test is failing because we don't have the model
layer yet:
----
self.assertEqual(list_.owner, user)
AttributeError: 'List' object has no attribute 'owner'
----
NOTE: You won't see this error unless you actually check out the old code
and revert 'lists/models.py'. You should definitely do this; part of
the objective of this appendix is to see whether we really can write
tests for a models layer that doesn't exist yet.
A First Attempt at Using Mocks for Isolation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("isolation, ensuring", "using mocks for", secondary-sortas="mocks for", id="IEmock23")))((("mocks", "isolating tests using", id="Misolate23")))Lists
don't have owners yet, but we can let the views layer tests pretend they
do by using a bit of mocking:
//IDEA: rename all "mockList" to "mockListClass"...
[role="sourcecode"]
.lists/tests/test_views.py (ch20l003)
====
[source,python]
----
from unittest.mock import patch
[...]
@patch('lists.views.List') #<1>
@patch('lists.views.ItemForm') #<2>
def test_list_owner_is_saved_if_user_is_authenticated(
self, mockItemFormClass, mockListClass #<3>
):
user = User.objects.create(email='a@b.com')
self.client.force_login(user)
self.client.post('/lists/new', data={'text': 'new item'})
mock_list = mockListClass.return_value #<4>
self.assertEqual(mock_list.owner, user) #<5>
----
====
<1> We mock out the `List` class to be able to get access to any lists
that might be created by the view.
<2> We also mock out the `ItemForm`. Otherwise, our form will
raise an error when we call `form.save()`, because it can't use a
mock object as the foreign key for the +Item+ it wants to create.
Once you start mocking, it can be hard to stop!
<3> The mock objects are injected into the test's arguments in the
opposite order to which they're declared. Tests with lots of mocks
often have this strange signature, with the dangling `):`. You get
used to it!
<4> The list instance that the view will have access to
will be the return value of the mocked `List` class.
<5> And we can make assertions about whether the `.owner` attribute is set on
it.
If we try to run this test now, it should pass:
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test lists*]
[...]
Ran 37 tests in 0.145s
OK
----
If you don't see a pass, make sure that your views code in 'views.py' is
exactly as I've shown it, using `List()`, not `List.objects.create`.
NOTE: Using mocks does tie you to specific ways of using an API. This is one
of the many trade-offs involved in the use of mock objects.
Using Mock side_effects to Check the Sequence of Events
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The trouble with this test is that it can still let us get away with writing
the wrong code by mistake. Imagine if we accidentally call +save+ before we
we assign the owner:
[role="sourcecode"]
.lists/views.py
====
[source,python]
----
if form.is_valid():
list_ = List()
list_.save()
list_.owner = request.user
form.save(for_list=list_)
return redirect(list_)
----
====
The test, as it's written now, still passes:
----
OK
----
So strictly speaking, we need to check not just that the owner is assigned, but that
it's assigned 'before' we call +save+ on our list object.
Here's how we could test the sequence of events using mocks--you can mock out
a function, and use it as a spy to check on the state of the world at the
moment it's called:
[role="sourcecode"]
.lists/tests/test_views.py (ch20l005)
====
[source,python]
----
@patch('lists.views.List')
@patch('lists.views.ItemForm')
def test_list_owner_is_saved_if_user_is_authenticated(
self, mockItemFormClass, mockListClass
):
user = User.objects.create(email='a@b.com')
self.client.force_login(user)
mock_list = mockListClass.return_value
def check_owner_assigned(): #<1>
self.assertEqual(mock_list.owner, user)
mock_list.save.side_effect = check_owner_assigned #<2>
self.client.post('/lists/new', data={'text': 'new item'})
mock_list.save.assert_called_once_with() #<3>
----
====
<1> We define a function that makes the assertion about the thing we
want to happen first: checking that the list's owner has been set.
<2> We assign that check function as a `side_effect` to the thing we
want to check happened second. When the view calls our mocked
save function, it will go through this assertion. We make sure to
set this up before we actually call the function we're testing.
<3> Finally, we make sure that the function with the `side_effect` was
actually triggered--that is, that we did `.save()`. Otherwise, our
assertion may actually never have been run.
TIP: Two common mistakes when you're using mock side effects are assigning the
side effect too late (i.e., 'after' you call the function under test), and
forgetting to check that the side-effect function was actually called. And
by common, I mean, "I made both these mistakes several times _while writing
this chapter_.”
At this point, if you've still got the "broken" code from earlier, where we
assign the owner but call +save+ in the wrong order, you should now see a
fail:
----
FAIL: test_list_owner_is_saved_if_user_is_authenticated
(lists.tests.test_views.NewListTest)
[...]
File "...goat-book/lists/views.py", line 17, in new_list
list_.save()
[...]
File "...goat-book/lists/tests/test_views.py", line 74, in
check_owner_assigned
self.assertEqual(mock_list.owner, user)
AssertionError: <MagicMock name='List().owner' id='140691452447208'> != <User:
User object>
----
Notice how the failure happens when we try to save, and then go inside
our `side_effect` function.
We can get it passing again like this:
[role="sourcecode"]
.lists/views.py
====
[source,python]
----
if form.is_valid():
list_ = List()
list_.owner = request.user
list_.save()
form.save(for_list=list_)
return redirect(list_)
----
====
//006
...
----
OK
----
((("", startref="IEmock23")))((("", startref="Misolate23")))But, boy, that's getting to be an ugly test!
Listen to Your Tests: Ugly Tests Signal a Need to Refactor
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("isolation, ensuring", "refactoring ugly tests")))((("refactoring")))Whenever
you find yourself having to write a test like this, and you're finding
it hard work, it's likely that your tests are trying to tell you something.
Eight lines of setup (two lines for mocks, three to set up a user, and three more for our side-effect function) is way too many.
What this test is trying to tell us is that our view is doing too much work,
dealing with creating a form, creating a new list object, 'and' deciding whether
or not to save an owner for the list.
We've already seen that we can make our views simpler and easier to understand
by pushing some of the work down to a form class. Why does the view need to
create the list object? Perhaps our `ItemForm.save` could do that? And why
does the view need to make decisions about whether or not to save the
`request.user`? Again, the form could do that.
While we're giving this form more responsibilities, it feels like it should
probably get a new name too. We could call it `NewListForm` instead, since
that's a better representation of what it does...something like this?
[role="sourcecode skipme"]
.lists/views.py
====
[source,python]
----
# don't enter this code yet, we're only imagining it.
def new_list(request):
form = NewListForm(data=request.POST)
if form.is_valid():
list_ = form.save(owner=request.user) # creates both List and Item
return redirect(list_)
else:
return render(request, 'home.html', {"form": form})
----
====
That would be neater! Let's see how we'd get to that state by using
fully isolated tests.
Rewriting Our Tests for the View to Be Fully Isolated
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
((("isolation, ensuring", "view layer", id="IEviews23")))Our
first attempt at a test suite for this view was highly 'integrated'. It
needed the database layer and the forms layer to be fully functional in order
for it to pass. We've started trying to make it more isolated, so let's now go
all the way.
Keep the Old Integrated Test Suite Around as a Sense Check
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Let's rename our old `NewListTest` class to `NewListViewIntegratedTest`,
and throw away our attempt at a mocky test for saving the owner, putting
back the integrated version, with a skip on it for now:
[role="sourcecode"]
.lists/tests/test_views.py (ch20l008)
====
[source,python]
----
import unittest
[...]
class NewListViewIntegratedTest(TestCase):
def test_can_save_a_POST_request(self):
[...]
@unittest.skip
def test_list_owner_is_saved_if_user_is_authenticated(self):
user = User.objects.create(email='a@b.com')
self.client.force_login(user)
self.client.post('/lists/new', data={'text': 'new item'})
list_ = List.objects.first()
self.assertEqual(list_.owner, user)
----
====
TIP: Have you heard the term "integration test" and are wondering what the
difference is from an "integrated test"? Go and take a peek at the
definitions box in <<chapter_27_hot_lava>>.
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test lists*]
[...]
Ran 37 tests in 0.139s
OK
----
A New Test Suite with Full Isolation
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Let's start with a blank slate, and see if we can use isolated tests to drive
a replacement of our `new_list` view. We'll call it `new_list2`, build it
alongside the old view, and when we're ready, swap it in and see if
the old integrated tests all still pass:
[role="sourcecode"]
.lists/views.py (ch20l009)
====
[source,python]
----
def new_list(request):
[...]
def new_list2(request):
pass
----
====
Thinking in Terms of Collaborators
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In order to rewrite our tests to be fully isolated, we need to throw out our
old way of thinking about the tests in terms of the "real" effects of the view
on things like the database, and instead think of it in terms of the objects it
collaborates with, and how it interacts with them.
In the new world, the view's main collaborator will be a form object, so we
mock that out in order to be able to fully control it, and in order to be able
to define, by wishful thinking, the way we want our form to work:
[role="sourcecode"]
.lists/tests/test_views.py (ch20l010)
====
[source,python]
----
from unittest.mock import patch
from django.http import HttpRequest
from lists.views import new_list2
[...]
@patch('lists.views.NewListForm') #<2>
class NewListViewUnitTest(unittest.TestCase): #<1>
def setUp(self):
self.request = HttpRequest()
self.request.POST['text'] = 'new list item' #<3>
def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
new_list2(self.request)
mockNewListForm.assert_called_once_with(data=self.request.POST) #<4>
----
====
<1> The Django `TestCase` class makes it too easy to write integrated tests.
As a way of making sure we're writing "pure", isolated unit tests, we'll
only use `unittest.TestCase`.
<2> We mock out the +NewListForm+ class (which doesn't even exist yet). It's
going to be used in all the tests, so we mock it out at the class level.
<3> We set up a basic POST request in `setUp`, building up the request by
hand rather than using the (overly integrated) Django Test Client.
<4> And we check the first thing about our new view: it initialises its
collaborator, the `NewListForm`, with the correct constructor--the
data from the request.
That will start with a failure, saying we don't have a `NewListForm` in
our view yet:
----
AttributeError: <module 'lists.views' from '...goat-book/lists/views.py'>
does not have the attribute 'NewListForm'
----
Let's create a placeholder for it:
[role="sourcecode"]
.lists/views.py (ch20l011)
====
[source,python]
----
from lists.forms import ExistingListItemForm, ItemForm, NewListForm
[...]
----
====
and:
[role="sourcecode"]
.lists/forms.py (ch20l012)
====
[source,python]
----
class ItemForm(forms.models.ModelForm):
[...]
class NewListForm(object):
pass
class ExistingListItemForm(ItemForm):
[...]
----
====
Next we get a real failure:
----
AssertionError: Expected 'NewListForm' to be called once. Called 0 times.
----
And we implement like this:
[role="sourcecode"]
.lists/views.py (ch20l012-2)
====
[source,python]
----
def new_list2(request):
NewListForm(data=request.POST)
----
====
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test lists*]
[...]
Ran 38 tests in 0.143s
OK
----
Let's continue. If the form is valid, we want to call +save+ on it:
[role="sourcecode"]
.lists/tests/test_views.py (ch20l013)
====
[source,python]
----
from unittest.mock import patch, Mock
[...]
@patch('lists.views.NewListForm')
class NewListViewUnitTest(unittest.TestCase):
def setUp(self):
self.request = HttpRequest()
self.request.POST['text'] = 'new list item'
self.request.user = Mock()
def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
new_list2(self.request)
mockNewListForm.assert_called_once_with(data=self.request.POST)
def test_saves_form_with_owner_if_form_valid(self, mockNewListForm):
mock_form = mockNewListForm.return_value
mock_form.is_valid.return_value = True
new_list2(self.request)
mock_form.save.assert_called_once_with(owner=self.request.user)
----
====
[role="pagebreak-before"]
That takes us to this:
[role="sourcecode"]
.lists/views.py (ch20l014)
====
[source,python]
----
def new_list2(request):
form = NewListForm(data=request.POST)
form.save(owner=request.user)
----
====
In the case where the form is valid, we want the view to return a redirect,
to send us to see the object that the form has just created. So we mock out
another of the view's collaborators, the `redirect` function:
[role="sourcecode"]
.lists/tests/test_views.py (ch20l015)
====
[source,python]
----
@patch('lists.views.redirect') #<1>
def test_redirects_to_form_returned_object_if_form_valid(
self, mock_redirect, mockNewListForm #<2>
):
mock_form = mockNewListForm.return_value
mock_form.is_valid.return_value = True #<3>
response = new_list2(self.request)
self.assertEqual(response, mock_redirect.return_value) #<4>
mock_redirect.assert_called_once_with(mock_form.save.return_value) #<5>
----
====
<1> We mock out the `redirect` function, this time at the method level.
<2> `patch` decorators are applied innermost first, so the new mock is injected
to our method before the `mockNewListForm`.
<3> We specify that we're testing the case where the form is valid.
<4> We check that the response from the view is the result of the `redirect`
function.
<5> And we check that the redirect function was called with the object that
the form returns on save.
That takes us to here:
[role="sourcecode"]
.lists/views.py (ch20l016)
====
[source,python]
----
def new_list2(request):
form = NewListForm(data=request.POST)
list_ = form.save(owner=request.user)
return redirect(list_)
----
====
[subs="specialcharacters,macros"]
----
$ pass:quotes[*python manage.py test lists*]
[...]
Ran 40 tests in 0.163s
OK
----
And now the failure case--if the form is invalid, we want to render
the home page template:
[role="sourcecode"]
.lists/tests/test_views.py (ch20l017)
====
[source,python]
----
@patch('lists.views.render')
def test_renders_home_template_with_form_if_form_invalid(
self, mock_render, mockNewListForm
):
mock_form = mockNewListForm.return_value
mock_form.is_valid.return_value = False
response = new_list2(self.request)
self.assertEqual(response, mock_render.return_value)
mock_render.assert_called_once_with(
self.request, 'home.html', {'form': mock_form}
)
----
====
That gives us:
----
AssertionError: <HttpResponseRedirect status_code=302, "te[114 chars]%3E"> !=
<MagicMock name='render()' id='140244627467408'>
----
TIP: When using assert methods on mocks, like +assert_called_​once_with+,
it's doubly important to make sure you run the test and see it fail.
It's all too easy to make a typo in your assert function name and
end up calling a mock method that does nothing (mine was to write
`asssert_called_once_with` with three essses; try it!).
//TODO: this is now a duplicate warning compared to mocking chapter.
// replace all assert_calleds with self.assertEquals?
We make a deliberate mistake, just to make sure our tests are comprehensive:
[role="sourcecode"]
.lists/views.py (ch20l018)
====
[source,python]
----
def new_list2(request):
form = NewListForm(data=request.POST)
list_ = form.save(owner=request.user)
if form.is_valid():
return redirect(list_)
return render(request, 'home.html',
gitextract_l59mn1di/
├── !README_FOR_PRODUCTION.txt
├── .dockerignore
├── .git-blame-ignore-revs
├── .github/
│ └── workflows/
│ └── tests.yml
├── .gitignore
├── .gitmodules
├── .python-version
├── CITATION.md
├── Dockerfile
├── ER_sampleTOC.html
├── LICENSE.md
├── Makefile
├── README.md
├── Vagrantfile
├── acknowledgments.asciidoc
├── ai_preface.asciidoc
├── analytics.html
├── appendix_CD.asciidoc
├── appendix_DjangoRestFramework.asciidoc
├── appendix_Django_Class-Based_Views.asciidoc
├── appendix_IV_testing_migrations.asciidoc
├── appendix_IX_cheat_sheet.asciidoc
├── appendix_X_what_to_do_next.asciidoc
├── appendix_bdd.asciidoc
├── appendix_fts_for_external_dependencies.asciidoc
├── appendix_github_links.asciidoc
├── appendix_logging.asciidoc
├── appendix_purist_unit_tests.asciidoc
├── appendix_rest_api.asciidoc
├── appendix_tradeoffs.asciidoc
├── asciidoc.conf
├── asciidoctor.css
├── atlas.json
├── author_bio.html
├── bibliography.asciidoc
├── book.asciidoc
├── buy_the_book_banner.html
├── chapter_01.asciidoc
├── chapter_02_unittest.asciidoc
├── chapter_03_unit_test_first_view.asciidoc
├── chapter_04_philosophy_and_refactoring.asciidoc
├── chapter_05_post_and_database.asciidoc
├── chapter_06_explicit_waits_1.asciidoc
├── chapter_07_working_incrementally.asciidoc
├── chapter_08_prettification.asciidoc
├── chapter_09_docker.asciidoc
├── chapter_10_production_readiness.asciidoc
├── chapter_11_server_prep.asciidoc
├── chapter_12_ansible.asciidoc
├── chapter_13_organising_test_files.asciidoc
├── chapter_14_database_layer_validation.asciidoc
├── chapter_15_simple_form.asciidoc
├── chapter_16_advanced_forms.asciidoc
├── chapter_17_javascript.asciidoc
├── chapter_18_second_deploy.asciidoc
├── chapter_19_spiking_custom_auth.asciidoc
├── chapter_20_mocking_1.asciidoc
├── chapter_21_mocking_2.asciidoc
├── chapter_22_fixtures_and_wait_decorator.asciidoc
├── chapter_23_debugging_prod.asciidoc
├── chapter_24_outside_in.asciidoc
├── chapter_25_CI.asciidoc
├── chapter_26_page_pattern.asciidoc
├── chapter_27_hot_lava.asciidoc
├── check-links.py
├── coderay-asciidoctor.css
├── colo.html
├── copy_html_to_site_and_print_toc.py
├── copyright.html
├── count-todos.py
├── cover.html
├── disqus_comments.html
├── docs/
│ ├── ORM_style_guide.htm
│ ├── ORM_style_guide_files/
│ │ └── main.css
│ ├── asciidoc-cheatsheet.html
│ ├── asciidoc-cheatsheet_files/
│ │ ├── Content.css
│ │ ├── asciidoc.asc
│ │ ├── asciidoc.css
│ │ ├── asciidoc.js
│ │ ├── jquery-1.js
│ │ └── pygments.css
│ ├── asciidoc-userguide.html
│ ├── asciidoc-userguide_files/
│ │ ├── Content.css
│ │ ├── asciidoc.css
│ │ ├── asciidoc.js
│ │ └── layout2.css
│ └── example_book.txt
├── epilogue.asciidoc
├── index.txt
├── ix.html
├── load_toc.js
├── misc/
│ ├── chapters.rst
│ ├── chapters_v2.rst
│ ├── chimera_comments_scraper.py
│ ├── curl
│ ├── get_stats.py
│ ├── get_stats.sh
│ ├── isolation-talks/
│ │ ├── djangoisland.md
│ │ ├── djangoisland.py
│ │ ├── extra_styling_for_djangoisland.css
│ │ ├── outline.txt
│ │ └── webcast-commits.hist
│ ├── plot.py
│ ├── reddit_post.md
│ ├── redditnotesresponse.txt
│ ├── tdd-flowchart.dot
│ └── tdd_diagram.odp
├── outline_and_future_chapters.asciidoc
├── part1.asciidoc
├── part2.asciidoc
├── part3.asciidoc
├── part4.asciidoc
├── praise.forbook.asciidoc
├── praise.html
├── pre-requisite-installations.asciidoc
├── preface.asciidoc
├── pygments-default.css
├── pyproject.toml
├── rename-chapter.sh
├── research/
│ ├── js-testing.rst
│ └── literary_agencies.ods
├── run_test_tests.sh
├── server-quickstart.md
├── source/
│ ├── blackify-chap.sh
│ ├── feed-thru-cherry-picks.sh
│ ├── fix-commit-numbers.py
│ └── push-back.sh
├── tests/
│ ├── actual_manage_py_test.output
│ ├── book_parser.py
│ ├── book_tester.py
│ ├── chapters.py
│ ├── check_links.py
│ ├── conftest.py
│ ├── examples.py
│ ├── my-phantomjs-qunit-runner.js
│ ├── run-js-spec.py
│ ├── slimerjs-0.9.0/
│ │ ├── LICENSE
│ │ ├── README.md
│ │ ├── application.ini
│ │ ├── omni.ja
│ │ ├── slimerjs
│ │ ├── slimerjs.bat
│ │ └── slimerjs.py
│ ├── source_updater.py
│ ├── sourcetree.py
│ ├── test_appendix_DjangoRestFramework.py
│ ├── test_appendix_Django_Class-Based_Views.py
│ ├── test_appendix_bdd.py
│ ├── test_appendix_purist_unit_tests.py
│ ├── test_appendix_rest_api.py
│ ├── test_book_parser.py
│ ├── test_book_tester.py
│ ├── test_chapter_01.py
│ ├── test_chapter_02_unittest.py
│ ├── test_chapter_03_unit_test_first_view.py
│ ├── test_chapter_04_philosophy_and_refactoring.py
│ ├── test_chapter_05_post_and_database.py
│ ├── test_chapter_06_explicit_waits_1.py
│ ├── test_chapter_07_working_incrementally.py
│ ├── test_chapter_08_prettification.py
│ ├── test_chapter_09_docker.py
│ ├── test_chapter_10_production_readiness.py
│ ├── test_chapter_11_server_prep.py
│ ├── test_chapter_12_ansible.py
│ ├── test_chapter_13_organising_test_files.py
│ ├── test_chapter_14_database_layer_validation.py
│ ├── test_chapter_15_simple_form.py
│ ├── test_chapter_16_advanced_forms.py
│ ├── test_chapter_17_javascript.py
│ ├── test_chapter_19_spiking_custom_auth.py
│ ├── test_chapter_20_mocking_1.py
│ ├── test_chapter_21_mocking_2.py
│ ├── test_chapter_22_fixtures_and_wait_decorator.py
│ ├── test_chapter_23_debugging_prod.py
│ ├── test_chapter_24_outside_in.py
│ ├── test_chapter_25_CI.py
│ ├── test_chapter_26_page_pattern.py
│ ├── test_source_updater.py
│ ├── test_sourcetree.py
│ ├── test_write_to_file.py
│ ├── update_source_repo.py
│ └── write_to_file.py
├── theme/
│ ├── epub/
│ │ ├── epub.css
│ │ ├── epub.xsl
│ │ └── layout.html
│ ├── html/
│ │ └── html.xsl
│ ├── mobi/
│ │ ├── layout.html
│ │ ├── mobi.css
│ │ └── mobi.xsl
│ └── pdf/
│ ├── pdf.css
│ └── pdf.xsl
├── titlepage.html
├── toc.html
├── todos.txt
├── tools/
│ ├── figure_renaming_report.tsv
│ ├── intake_report.txt
│ └── oneoffs/
│ ├── oneoff.css
│ └── oneoff.xsl
├── video_plug.asciidoc
├── wordcount
└── workshops/
├── intermediate_workshop_notes.md
├── js-testing-with-jasmine.asciidoc
├── pycon.uk.2015.dirigible-talk.md
├── pycon.uk.2015.tutorial-beginners.md
├── pycon.us.2015.study-group.md
├── study-group.md
├── working-incrementally-handout.md
├── working-incrementally-notes.md
└── workshop.asciidoc
SYMBOL INDEX (519 symbols across 51 files)
FILE: check-links.py
function find_links (line 11) | def find_links(path):
function check_url (line 24) | async def check_url(url, client):
function main (line 37) | async def main(path):
FILE: copy_html_to_site_and_print_toc.py
function _chapters (line 24) | def _chapters():
class ChapterInfo (line 40) | class ChapterInfo(NamedTuple):
function make_chapters (line 47) | def make_chapters():
function parse_chapters (line 52) | def parse_chapters() -> Iterator[tuple[str, html.HtmlElement]]:
function get_anchor_targets (line 58) | def get_anchor_targets(parsed_html) -> list[str]:
function get_chapter_info (line 64) | def get_chapter_info():
function fix_xrefs (line 106) | def fix_xrefs(contents, chapter, chapter_info):
function fix_appendix_titles (line 124) | def fix_appendix_titles(contents, chapter, chapter_info):
function copy_chapters_across_with_fixes (line 133) | def copy_chapters_across_with_fixes(chapter_info, fixed_toc):
function extract_toc_from_book (line 166) | def extract_toc_from_book():
function fix_toc (line 172) | def fix_toc(toc, chapter_info):
function print_toc_md (line 195) | def print_toc_md(chapter_info):
function rsync_images (line 201) | def rsync_images():
function main (line 208) | def main():
FILE: docs/asciidoc-cheatsheet_files/asciidoc.js
function getText (line 23) | function getText(el) {
function TocEntry (line 34) | function TocEntry(el, text, toclevel) {
function tocEntries (line 40) | function tocEntries(el, toclevels) {
function reinstall (line 170) | function reinstall() {
function reinstallAndRemoveTimer (line 177) | function reinstallAndRemoveTimer() {
FILE: docs/asciidoc-cheatsheet_files/jquery-1.js
function color (line 21) | function color(a){if(!jQuery.browser.safari)return false;var ret=documen...
function handleHover (line 27) | function handleHover(e){var p=e.relatedTarget;while(p&&p!=this)try{p=p.p...
function bindReady (line 28) | function bindReady(){if(readyBound)return;readyBound=true;if(jQuery.brow...
function success (line 29) | function success(){if(s.success)s.success(data,status);if(s.global)jQuer...
function complete (line 29) | function complete(){if(s.complete)s.complete(xml,status);if(s.global)jQu...
function t (line 32) | function t(){return self.step();}
function border (line 32) | function border(elem){add(jQuery.css(elem,"borderLeftWidth"),jQuery.css(...
function add (line 32) | function add(l,t){left+=parseInt(l)||0;top+=parseInt(t)||0;}
FILE: docs/asciidoc-userguide_files/asciidoc.js
function getText (line 23) | function getText(el) {
function TocEntry (line 34) | function TocEntry(el, text, toclevel) {
function tocEntries (line 40) | function tocEntries(el, toclevels) {
function reinstall (line 170) | function reinstall() {
function reinstallAndRemoveTimer (line 177) | function reinstallAndRemoveTimer() {
FILE: misc/get_stats.py
function get_log (line 14) | def get_log():
function checkout_commit (line 25) | def checkout_commit(hash):
function get_wordcounts (line 29) | def get_wordcounts():
function main (line 43) | def main():
FILE: misc/isolation-talks/djangoisland.py
class List (line 33) | class List(models.Model):
class Item (line 37) | class Item(models.Model):
FILE: misc/plot.py
function get_data_from_csv (line 6) | def get_data_from_csv():
FILE: tests/book_parser.py
class CodeListing (line 7) | class CodeListing:
method __init__ (line 10) | def __init__(self, filename, contents):
method is_diff (line 29) | def is_diff(self):
method type (line 37) | def type(self):
method __repr__ (line 49) | def __repr__(self):
class Command (line 53) | class Command(str):
method __init__ (line 54) | def __init__(self, a_string):
method type (line 65) | def type(self):
method __repr__ (line 89) | def __repr__(self):
class Output (line 93) | class Output(str):
method __init__ (line 94) | def __init__(self, a_string):
method type (line 104) | def type(self) -> str:
function fix_newlines (line 113) | def fix_newlines(text):
function parse_output (line 119) | def parse_output(listing):
function _strip_callouts (line 151) | def _strip_callouts(content):
function parse_listing (line 160) | def parse_listing(listing): # noqa: PLR0912
function get_commands (line 231) | def get_commands(node):
FILE: tests/book_tester.py
function contains (line 28) | def contains(inseq, subseq):
function wrap_long_lines (line 35) | def wrap_long_lines(text):
function split_blocks (line 43) | def split_blocks(text):
function fix_test_dashes (line 50) | def fix_test_dashes(output):
function strip_mock_ids (line 54) | def strip_mock_ids(output):
function strip_object_ids (line 68) | def strip_object_ids(output):
function strip_migration_timestamps (line 72) | def strip_migration_timestamps(output):
function strip_localhost_port (line 76) | def strip_localhost_port(output):
function strip_selenium_trace_ids (line 82) | def strip_selenium_trace_ids(output):
function fix_firefox_esr_version (line 106) | def fix_firefox_esr_version(output):
function strip_session_ids (line 110) | def strip_session_ids(output):
function standardise_assertionerror_none (line 114) | def standardise_assertionerror_none(output):
function standardise_git_init_msg (line 118) | def standardise_git_init_msg(output):
function strip_git_hashes (line 124) | def strip_git_hashes(output):
function strip_callouts (line 145) | def strip_callouts(output):
function standardise_library_paths (line 161) | def standardise_library_paths(output):
function standardise_geckodriver_tracebacks (line 170) | def standardise_geckodriver_tracebacks(output):
function strip_test_speed (line 179) | def strip_test_speed(output):
function strip_js_test_speed (line 187) | def strip_js_test_speed(output):
function strip_bdd_test_speed (line 195) | def strip_bdd_test_speed(output):
function strip_screenshot_timestamps (line 203) | def strip_screenshot_timestamps(output):
function strip_docker_image_ids_and_creation_times (line 214) | def strip_docker_image_ids_and_creation_times(output):
function fix_curl_stuff (line 223) | def fix_curl_stuff(output):
function fix_curl_linebreak_after_download (line 247) | def fix_curl_linebreak_after_download(output):
function fix_sqlite_messages (line 262) | def fix_sqlite_messages(actual_text):
function standardize_layout_test_pixelsize (line 269) | def standardize_layout_test_pixelsize(actual_text):
function fix_creating_database_line (line 277) | def fix_creating_database_line(actual_text):
function fix_interactive_managepy_stuff (line 287) | def fix_interactive_managepy_stuff(actual_text):
class ChapterTest (line 297) | class ChapterTest(unittest.TestCase):
method setUp (line 301) | def setUp(self):
method tearDown (line 310) | def tearDown(self):
method parse_listings (line 317) | def parse_listings(self):
method check_final_diff (line 334) | def check_final_diff(self, ignore=None, diff=None):
method start_with_checkout (line 360) | def start_with_checkout(self):
method write_to_file (line 364) | def write_to_file(self, codelisting):
method apply_patch (line 373) | def apply_patch(self, codelisting):
method run_command (line 395) | def run_command(self, command, cwd=None, user_input=None, ignore_error...
method prep_virtualenv (line 417) | def prep_virtualenv(self):
method prep_database (line 432) | def prep_database(self):
method assertLineIn (line 435) | def assertLineIn(self, line, lines):
method assert_console_output_correct (line 443) | def assert_console_output_correct(self, actual, expected, ls=False):
method find_with_check (line 541) | def find_with_check(self, pos, expected_content):
method skip_with_check (line 565) | def skip_with_check(self, pos, expected_content):
method replace_command_with_check (line 569) | def replace_command_with_check(self, pos, old, new):
method skip_forward_if_skipto_set (line 582) | def skip_forward_if_skipto_set(self) -> None:
method _run_tree (line 598) | def _run_tree(self, target="", no_report=False):
method assert_directory_tree_correct (line 603) | def assert_directory_tree_correct(self, expected_tree):
method assert_all_listings_checked (line 607) | def assert_all_listings_checked(self, listings, exceptions=[]):
method check_test_code_cycle (line 627) | def check_test_code_cycle(self, pos, test_command_in_listings=True, ft...
method unset_PYTHONDONTWRITEBYTECODE (line 642) | def unset_PYTHONDONTWRITEBYTECODE(self):
method run_test_and_check_result (line 647) | def run_test_and_check_result(self, bdd=False):
method run_js_tests (line 659) | def run_js_tests(self, tests_path: Path):
method check_jasmine_output (line 669) | def check_jasmine_output(self, expected_output):
method check_current_contents (line 675) | def check_current_contents(self, listing, actual_contents):
method check_commit (line 696) | def check_commit(self, pos):
method check_diff_or_status (line 710) | def check_diff_or_status(self, pos):
method _manage_py (line 749) | def _manage_py(self):
method start_dev_server (line 756) | def start_dev_server(self):
method restart_dev_server (line 761) | def restart_dev_server(self):
method run_unit_tests (line 768) | def run_unit_tests(self):
method run_fts (line 776) | def run_fts(self):
method run_interactive_manage_py (line 788) | def run_interactive_manage_py(self, listing):
method recognise_listing_and_process_it (line 855) | def recognise_listing_and_process_it(self):
FILE: tests/run-js-spec.py
function sub_book_path (line 12) | def sub_book_path(text: str) -> str:
function run (line 20) | def run(path: Path):
FILE: tests/slimerjs-0.9.0/slimerjs.py
function resolve (line 10) | def resolve(path):
function is_exe (line 16) | def is_exe(fpath):
function which (line 19) | def which(program):
function showHelp (line 74) | def showHelp():
FILE: tests/source_updater.py
class SourceUpdateError (line 12) | class SourceUpdateError(Exception):
function get_indent (line 16) | def get_indent(line):
class Block (line 20) | class Block(object):
method __init__ (line 22) | def __init__(self, node, source):
method is_view (line 34) | def is_view(self):
method last_line (line 39) | def last_line(self):
class Source (line 53) | class Source(object):
method __init__ (line 55) | def __init__(self):
method from_path (line 59) | def from_path(kls, path):
method _from_contents (line 69) | def _from_contents(kls, contents):
method lines (line 76) | def lines(self):
method functions (line 81) | def functions(self):
method views (line 92) | def views(self):
method ast (line 97) | def ast(self):
method classes (line 105) | def classes(self):
method _import_nodes (line 116) | def _import_nodes(self):
method _deduped_import_nodes (line 123) | def _deduped_import_nodes(self):
method imports (line 139) | def imports(self):
method django_imports (line 144) | def django_imports(self):
method project_imports (line 148) | def project_imports(self):
method general_imports (line 152) | def general_imports(self):
method fixed_imports (line 156) | def fixed_imports(self):
method find_first_nonimport_line (line 171) | def find_first_nonimport_line(self):
method replace_function (line 185) | def replace_function(self, new_lines):
method remove_function (line 198) | def remove_function(self, function_name):
method find_start_line (line 209) | def find_start_line(self, new_lines):
method add_to_class (line 222) | def add_to_class(self, classname, new_lines):
method find_end_line (line 236) | def find_end_line(self, new_lines):
method add_imports (line 251) | def add_imports(self, imports):
method update (line 260) | def update(self, new_contents):
method get_updated_contents (line 264) | def get_updated_contents(self):
method write (line 270) | def write(self):
FILE: tests/sourcetree.py
function strip_comments (line 13) | def strip_comments(line):
class Commit (line 28) | class Commit:
method from_diff (line 32) | def from_diff(commit_info):
method all_lines (line 36) | def all_lines(self):
method lines_to_add (line 40) | def lines_to_add(self):
method lines_to_remove (line 48) | def lines_to_remove(self):
method moved_lines (line 56) | def moved_lines(self):
method deleted_lines (line 60) | def deleted_lines(self):
method new_lines (line 64) | def new_lines(self):
method stripped_lines_to_add (line 68) | def stripped_lines_to_add(self):
class ApplyCommitException (line 72) | class ApplyCommitException(Exception):
class SourceTree (line 76) | class SourceTree:
method __init__ (line 77) | def __init__(self):
method get_contents (line 82) | def get_contents(self, path):
method cleanup (line 86) | def cleanup(self):
method run_command (line 95) | def run_command(
method get_local_repo_path (line 173) | def get_local_repo_path(self, chapter_name):
method start_with_checkout (line 181) | def start_with_checkout(self, chapter, previous_chapter):
method get_commit_spec (line 191) | def get_commit_spec(self, commit_ref):
method get_files_from_commit_spec (line 194) | def get_files_from_commit_spec(self, commit_spec):
method show_future_version (line 199) | def show_future_version(self, commit_spec, path):
method patch_from_commit (line 202) | def patch_from_commit(self, commit_ref, path=None):
method tidy_up_after_patches (line 212) | def tidy_up_after_patches(self):
method apply_listing_from_commit (line 216) | def apply_listing_from_commit(self, listing):
method _check_listing_matches_commit (line 233) | def _check_listing_matches_commit(self, listing, commit_spec, future_c...
function check_chunks_against_future_contents (line 261) | def check_chunks_against_future_contents(listing_contents, future_conten...
function get_offset (line 289) | def get_offset(lines, future_lines):
function reindent_to_match (line 303) | def reindent_to_match(code, future_lines):
function split_into_chunks (line 308) | def split_into_chunks(code):
FILE: tests/test_appendix_DjangoRestFramework.py
class AppendixVIITest (line 7) | class AppendixVIITest(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_appendix_Django_Class-Based_Views.py
class AppendixIITest (line 7) | class AppendixIITest(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_appendix_bdd.py
class AppendixVTest (line 7) | class AppendixVTest(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_appendix_purist_unit_tests.py
class Chapter20Test (line 7) | class Chapter20Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_appendix_rest_api.py
class AppendixVITest (line 7) | class AppendixVITest(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_book_parser.py
class CodeListingTest (line 19) | class CodeListingTest(unittest.TestCase):
method test_stringify (line 20) | def test_stringify(self):
method test_server_codelisting (line 26) | def test_server_codelisting(self):
class CommitRefFinderTest (line 33) | class CommitRefFinderTest(unittest.TestCase):
method test_base_finder (line 34) | def test_base_finder(self):
method test_finder_on_codelisting (line 39) | def test_finder_on_codelisting(self):
class ParseCodeListingTest (line 47) | class ParseCodeListingTest(unittest.TestCase):
method test_recognises_code_listings (line 48) | def test_recognises_code_listings(self):
method test_recognises_git_commit_refs (line 72) | def test_recognises_git_commit_refs(self):
method test_recognises_git_commit_refs_even_if_formatted_as_diffs (line 85) | def test_recognises_git_commit_refs_even_if_formatted_as_diffs(self):
method test_recognises_diffs_even_if_they_dont_have_atat (line 99) | def test_recognises_diffs_even_if_they_dont_have_atat(self):
method test_recognises_skipme_tag_on_unmarked_code_listing (line 106) | def test_recognises_skipme_tag_on_unmarked_code_listing(self):
method test_recognises_skipme_tag_on_code_listing (line 114) | def test_recognises_skipme_tag_on_code_listing(self):
method test_recognises_currentcontents_tag (line 122) | def test_recognises_currentcontents_tag(self):
method test_recognises_dofirst_tag (line 131) | def test_recognises_dofirst_tag(self):
method test_recognises_jasmine_tag (line 139) | def test_recognises_jasmine_tag(self):
method test_recognises_server_commands (line 159) | def test_recognises_server_commands(self):
method test_recognises_virtualenv_commands (line 169) | def test_recognises_virtualenv_commands(self):
method test_recognises_command_with_ats (line 181) | def test_recognises_command_with_ats(self):
method test_can_extract_one_command_and_its_output (line 191) | def test_can_extract_one_command_and_its_output(self):
method test_extracting_multiple (line 216) | def test_extracting_multiple(self):
method test_post_command_comment_with_multiple_spaces (line 247) | def test_post_command_comment_with_multiple_spaces(self):
method test_catches_command_with_trailing_comment (line 277) | def test_catches_command_with_trailing_comment(self):
method test_handles_multiline_commands (line 298) | def test_handles_multiline_commands(self):
method test_handles_inline_inputs (line 323) | def test_handles_inline_inputs(self):
method test_strips_asciidoctor_callouts_from_code (line 345) | def test_strips_asciidoctor_callouts_from_code(self):
method test_strips_asciidoctor_callouts_from_output (line 359) | def test_strips_asciidoctor_callouts_from_output(self):
method test_strip_callouts_helper (line 368) | def test_strip_callouts_helper(self):
class GetCommandsTest (line 395) | class GetCommandsTest(unittest.TestCase):
method test_extracting_one_command (line 396) | def test_extracting_one_command(self):
method test_extracting_multiple (line 402) | def test_extracting_multiple(self):
FILE: tests/test_book_tester.py
class WrapLongLineTest (line 24) | class WrapLongLineTest(unittest.TestCase):
method test_wrap_long_lines_with_words (line 25) | def test_wrap_long_lines_with_words(self):
method test_wrap_long_lines_with_words_2 (line 42) | def test_wrap_long_lines_with_words_2(self):
method test_wrap_long_lines_with_words_3 (line 47) | def test_wrap_long_lines_with_words_3(self):
method test_wrap_long_lines_doesnt_swallow_spaces (line 52) | def test_wrap_long_lines_doesnt_swallow_spaces(self):
method test_wrap_long_lines_with_unbroken_chars (line 58) | def test_wrap_long_lines_with_unbroken_chars(self):
method test_wrap_long_lines_with_unbroken_chars_2 (line 73) | def test_wrap_long_lines_with_unbroken_chars_2(self):
method test_wrap_long_lines_with_indent (line 86) | def test_wrap_long_lines_with_indent(self):
class RunCommandTest (line 108) | class RunCommandTest(ChapterTest):
method test_calls_sourcetree_run_command_and_marks_as_run (line 109) | def test_calls_sourcetree_run_command_and_marks_as_run(self):
method test_raises_if_not_command (line 122) | def test_raises_if_not_command(self):
class GetListingsTest (line 127) | class GetListingsTest(ChapterTest):
method test_get_listings_gets_exampleblock_code_listings_and_regular_listings (line 130) | def test_get_listings_gets_exampleblock_code_listings_and_regular_list...
class AssertConsoleOutputCorrectTest (line 141) | class AssertConsoleOutputCorrectTest(ChapterTest):
method test_simple_case (line 142) | def test_simple_case(self):
method test_ignores_test_run_times_and_test_dashes (line 148) | def test_ignores_test_run_times_and_test_dashes(self):
method test_handles_elipsis (line 171) | def test_handles_elipsis(self):
method test_handles_elipsis_at_end_of_line_where_theres_actually_a_linebreak (line 192) | def test_handles_elipsis_at_end_of_line_where_theres_actually_a_linebr...
method test_with_start_elipsis_and_OK (line 210) | def test_with_start_elipsis_and_OK(self):
method test_with_elipsis_finds_assertionerrors (line 232) | def test_with_elipsis_finds_assertionerrors(self):
method test_with_start_elipsis_and_end_longline_elipsis (line 256) | def test_with_start_elipsis_and_end_longline_elipsis(self):
method test_with_start_elipsis_and_end_longline_elipsis_with_assertionerror (line 281) | def test_with_start_elipsis_and_end_longline_elipsis_with_assertionerr...
method test_for_short_expected_with_trailing_elipsis (line 303) | def test_for_short_expected_with_trailing_elipsis(self):
method test_elipsis_lines_still_checked (line 324) | def test_elipsis_lines_still_checked(self):
method test_with_middle_elipsis (line 342) | def test_with_middle_elipsis(self):
method test_ls (line 370) | def test_ls(self):
method test_working_directory_substitution (line 376) | def test_working_directory_substitution(self):
method test_tabs (line 382) | def test_tabs(self):
method test_ignores_diff_indexes (line 388) | def test_ignores_diff_indexes(self):
method test_ignores_callouts (line 409) | def test_ignores_callouts(self):
method test_ignores_asciidoctor_callouts (line 428) | def test_ignores_asciidoctor_callouts(self):
method test_ignores_git_commit_numers_in_logs (line 447) | def test_ignores_git_commit_numers_in_logs(self):
method test_ignores_geckodriver_stacktrace_line_numbers (line 486) | def test_ignores_geckodriver_stacktrace_line_numbers(self):
method test_ignores_mock_ids (line 506) | def test_ignores_mock_ids(self):
method test_ignores_mock_ids_when_they_dont_have_names (line 525) | def test_ignores_mock_ids_when_they_dont_have_names(self):
method test_ignores_phantomjs_run_times (line 544) | def test_ignores_phantomjs_run_times(self):
method test_ignores_bdd_run_times (line 550) | def test_ignores_bdd_run_times(self):
method test_ignores_object_ids (line 556) | def test_ignores_object_ids(self):
method test_ignores_migration_timestamps (line 562) | def test_ignores_migration_timestamps(self):
method test_ignores_session_ids (line 568) | def test_ignores_session_ids(self):
method test_ignores_3_5_x_AssertionError_None_thing (line 574) | def test_ignores_3_5_x_AssertionError_None_thing(self):
method test_ignores_localhost_server_port_4digits (line 582) | def test_ignores_localhost_server_port_4digits(self):
method test_ignores_localhost_server_port_5_digits (line 588) | def test_ignores_localhost_server_port_5_digits(self):
method test_ignores_127_0_0_1_server_port_4digits (line 594) | def test_ignores_127_0_0_1_server_port_4digits(self):
method test_only_ignores_exactly_32_char_strings_no_whitespace (line 600) | def test_only_ignores_exactly_32_char_strings_no_whitespace(self):
method test_ignores_selenium_trace_log_ids (line 608) | def test_ignores_selenium_trace_log_ids(self):
method test_ignores_firefox_esr_version (line 632) | def test_ignores_firefox_esr_version(self):
method test_ignores_docker_image_ids_and_creation_time (line 644) | def test_ignores_docker_image_ids_and_creation_time(self):
method test_ignores_minor_differences_in_curl_output1 (line 653) | def test_ignores_minor_differences_in_curl_output1(self):
method test_ignores_minor_differences_in_curl_output2 (line 664) | def test_ignores_minor_differences_in_curl_output2(self):
method test_ignores_minor_differences_in_curl_output3 (line 673) | def test_ignores_minor_differences_in_curl_output3(self):
method test_ignores_minor_differences_in_curl_output4 (line 682) | def test_ignores_minor_differences_in_curl_output4(self):
method test_ignores_minor_differences_in_curl_output5 (line 691) | def test_ignores_minor_differences_in_curl_output5(self):
method test_ignores_git_localisation_uk_vs_usa (line 700) | def test_ignores_git_localisation_uk_vs_usa(self):
method test_ignores_screenshot_times (line 709) | def test_ignores_screenshot_times(self):
method test_matches_system_vs_virtualenv_install_paths (line 729) | def test_matches_system_vs_virtualenv_install_paths(self):
method test_fixes_stdout_stderr_for_creating_db (line 765) | def test_fixes_stdout_stderr_for_creating_db(self):
method test_handles_long_lines (line 803) | def test_handles_long_lines(self):
method test_for_minimal_expected (line 826) | def test_for_minimal_expected(self):
method test_for_long_traceback (line 861) | def test_for_long_traceback(self):
class CurrentContentsTest (line 904) | class CurrentContentsTest(ChapterTest):
method test_ok_for_correct_current_contents (line 905) | def test_ok_for_correct_current_contents(self):
method test_raises_for_any_line_not_in_actual_contents (line 927) | def test_raises_for_any_line_not_in_actual_contents(self):
method test_indentation_is_ignored (line 950) | def test_indentation_is_ignored(self):
method test_raises_if_lines_not_in_order (line 971) | def test_raises_if_lines_not_in_order(self):
method test_checks_elipsis_blocks_separately (line 995) | def test_checks_elipsis_blocks_separately(self):
method test_checks_ignores_blank_lines (line 1019) | def test_checks_ignores_blank_lines(self):
class SplitBlocksTest (line 1065) | class SplitBlocksTest(unittest.TestCase):
method test_splits_on_multi_newlines (line 1066) | def test_splits_on_multi_newlines(self):
method test_splits_on_elipsis (line 1078) | def test_splits_on_elipsis(self):
class TestContains (line 1091) | class TestContains:
method test_smoketest (line 1092) | def test_smoketest(self):
method test_contains_end_seq (line 1095) | def test_contains_end_seq(self):
method test_contains_middle_seq (line 1098) | def test_contains_middle_seq(self):
method test_contains_oversized_seq (line 1101) | def test_contains_oversized_seq(self):
method test_contains_iteslf (line 1104) | def test_contains_iteslf(self):
class CheckQunitOuptutTest (line 1109) | class CheckQunitOuptutTest(ChapterTest):
method test_partial_listing_passes (line 1110) | def test_partial_listing_passes(self):
method test_fails_if_lists_fail_and_no_accounts (line 1119) | def test_fails_if_lists_fail_and_no_accounts(self):
method TODOtest_runs_phantomjs_runner_against_lists_tests (line 1127) | def TODOtest_runs_phantomjs_runner_against_lists_tests(self):
class CheckFinalDiffTest (line 1144) | class CheckFinalDiffTest(ChapterTest):
method test_empty_passes (line 1147) | def test_empty_passes(self):
method test_diff_fails (line 1151) | def test_diff_fails(self):
method test_blank_lines_ignored (line 1163) | def test_blank_lines_ignored(self):
method test_ignore_moves (line 1174) | def test_ignore_moves(self):
method test_ignore_secret_key_and_generated_by_django (line 1194) | def test_ignore_secret_key_and_generated_by_django(self):
method test_ignore_moves_and_custom (line 1225) | def test_ignore_moves_and_custom(self):
FILE: tests/test_chapter_01.py
class Chapter1Test (line 15) | class Chapter1Test(ChapterTest):
method write_to_file (line 18) | def write_to_file(self, codelisting):
method test_listings_and_commands_and_output (line 24) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_02_unittest.py
class Chapter2Test (line 11) | class Chapter2Test(ChapterTest):
method test_listings_and_commands_and_output (line 15) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_03_unit_test_first_view.py
class Chapter3Test (line 13) | class Chapter3Test(ChapterTest):
method test_listings_and_commands_and_output (line 17) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_04_philosophy_and_refactoring.py
class Chapter4Test (line 13) | class Chapter4Test(ChapterTest):
method test_listings_and_commands_and_output (line 17) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_05_post_and_database.py
class Chapter5Test (line 12) | class Chapter5Test(ChapterTest):
method test_listings_and_commands_and_output (line 16) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_06_explicit_waits_1.py
class Chapter6Test (line 10) | class Chapter6Test(ChapterTest):
method test_listings_and_commands_and_output (line 14) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_07_working_incrementally.py
class Chapter7Test (line 10) | class Chapter7Test(ChapterTest):
method test_listings_and_commands_and_output (line 14) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_08_prettification.py
class Chapter8Test (line 8) | class Chapter8Test(ChapterTest):
method test_listings_and_commands_and_output (line 12) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_09_docker.py
class Chapter9Test (line 8) | class Chapter9Test(ChapterTest):
method test_listings_and_commands_and_output (line 12) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_10_production_readiness.py
class Chapter10Test (line 10) | class Chapter10Test(ChapterTest):
method test_listings_and_commands_and_output (line 14) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_11_server_prep.py
class Chapter11Test (line 8) | class Chapter11Test(ChapterTest):
method test_listings_and_commands_and_output (line 12) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_12_ansible.py
class Chapter12Test (line 8) | class Chapter12Test(ChapterTest):
method test_listings_and_commands_and_output (line 12) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_13_organising_test_files.py
class Chapter13Test (line 7) | class Chapter13Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_14_database_layer_validation.py
class Chapter13Test (line 7) | class Chapter13Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_15_simple_form.py
class Chapter15Test (line 7) | class Chapter15Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_16_advanced_forms.py
class Chapter16Test (line 8) | class Chapter16Test(ChapterTest):
method test_listings_and_commands_and_output (line 12) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_17_javascript.py
class Chapter16Test (line 7) | class Chapter16Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_19_spiking_custom_auth.py
class Chapter19Test (line 7) | class Chapter19Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_20_mocking_1.py
class Chapter20Test (line 7) | class Chapter20Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_21_mocking_2.py
class Chapter21Test (line 7) | class Chapter21Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_22_fixtures_and_wait_decorator.py
class Chapter22Test (line 7) | class Chapter22Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_23_debugging_prod.py
class Chapter18Test (line 7) | class Chapter18Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_24_outside_in.py
class Chapter24Test (line 7) | class Chapter24Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_25_CI.py
class Chapter25Test (line 7) | class Chapter25Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_chapter_26_page_pattern.py
class Chapter26Test (line 7) | class Chapter26Test(ChapterTest):
method test_listings_and_commands_and_output (line 11) | def test_listings_and_commands_and_output(self):
FILE: tests/test_source_updater.py
class SourceTest (line 10) | class SourceTest(unittest.TestCase):
method test_from_path_constructor_with_existing_file (line 12) | def test_from_path_constructor_with_existing_file(self):
method test_from_path_constructor_with_nonexistent_file (line 24) | def test_from_path_constructor_with_nonexistent_file(self):
method test_lines (line 31) | def test_lines(self):
method test_write_writes_new_content_to_path (line 37) | def test_write_writes_new_content_to_path(self):
class FunctionFinderTest (line 48) | class FunctionFinderTest(unittest.TestCase):
method test_function_object (line 50) | def test_function_object(self):
method test_finds_functions (line 62) | def test_finds_functions(self):
method test_finds_views (line 77) | def test_finds_views(self):
method test_finds_classes (line 98) | def test_finds_classes(self):
class ReplaceFunctionTest (line 115) | class ReplaceFunctionTest(unittest.TestCase):
method test_finding_last_line_in_function (line 117) | def test_finding_last_line_in_function(self):
method test_finding_last_line_in_function_with_brackets (line 127) | def test_finding_last_line_in_function_with_brackets(self):
method test_finding_last_line_in_function_with_brackets_before_another (line 139) | def test_finding_last_line_in_function_with_brackets_before_another(se...
method test_changing_the_end_of_a_method (line 156) | def test_changing_the_end_of_a_method(self):
class RemoveFunctionTest (line 205) | class RemoveFunctionTest(unittest.TestCase):
method test_removing_a_function (line 207) | def test_removing_a_function(self):
class AddToClassTest (line 245) | class AddToClassTest(unittest.TestCase):
method test_finding_class_info (line 247) | def test_finding_class_info(self):
method test_addding_to_class (line 286) | def test_addding_to_class(self):
method test_addding_to_class_fixes_indents_and_superfluous_lines (line 329) | def test_addding_to_class_fixes_indents_and_superfluous_lines(self):
class ImportsTest (line 360) | class ImportsTest(unittest.TestCase):
method test_finding_different_types_of_import (line 362) | def test_finding_different_types_of_import(self):
method test_find_first_nonimport_line (line 404) | def test_find_first_nonimport_line(self):
method test_find_first_nonimport_line_raises_if_imports_in_a_mess (line 420) | def test_find_first_nonimport_line_raises_if_imports_in_a_mess(self):
method test_fixed_imports (line 432) | def test_fixed_imports(self):
method test_add_import (line 496) | def test_add_import(self):
method test_add_import_chooses_longer_lines (line 545) | def test_add_import_chooses_longer_lines(self):
method test_add_import_ends_up_in_updated_contents_when_appending (line 569) | def test_add_import_ends_up_in_updated_contents_when_appending(self):
method test_add_import_ends_up_in_updated_contents_when_prepending (line 598) | def test_add_import_ends_up_in_updated_contents_when_prepending(self):
class LineFindingTests (line 628) | class LineFindingTests(unittest.TestCase):
method test_finding_start_line (line 630) | def test_finding_start_line(self):
method test_finding_end_line (line 654) | def test_finding_end_line(self):
method test_finding_end_line_depends_on_start (line 677) | def test_finding_end_line_depends_on_start(self):
class SourceUpdateTest (line 695) | class SourceUpdateTest(unittest.TestCase):
method test_update_with_empty_contents (line 697) | def test_update_with_empty_contents(self):
method test_adds_final_newline_if_necessary (line 702) | def test_adds_final_newline_if_necessary(self):
FILE: tests/test_sourcetree.py
class GetFileTest (line 18) | class GetFileTest(unittest.TestCase):
method test_get_contents (line 19) | def test_get_contents(self):
class StripCommentTest (line 25) | class StripCommentTest(unittest.TestCase):
method test_strips_python_comments (line 26) | def test_strips_python_comments(self):
method test_strips_js_comments (line 30) | def test_strips_js_comments(self):
method test_doesnt_break_comment_lines (line 34) | def test_doesnt_break_comment_lines(self):
method test_doesnt_break_trailing_slashes (line 37) | def test_doesnt_break_trailing_slashes(self):
class StartWithCheckoutTest (line 41) | class StartWithCheckoutTest(unittest.TestCase):
method test_get_local_repo_path (line 42) | def test_get_local_repo_path(self):
method test_checks_out_repo_chapter_as_main (line 48) | def test_checks_out_repo_chapter_as_main(self):
class ApplyFromGitRefTest (line 62) | class ApplyFromGitRefTest(unittest.TestCase):
method setUp (line 63) | def setUp(self):
method test_from_real_git_stuff (line 72) | def test_from_real_git_stuff(self):
method test_leaves_staging_empty (line 96) | def test_leaves_staging_empty(self):
method test_raises_if_wrong_file (line 113) | def test_raises_if_wrong_file(self):
method _checkout_commit (line 129) | def _checkout_commit(self, commit):
method test_raises_if_too_many_files_in_commit (line 134) | def test_raises_if_too_many_files_in_commit(self):
method test_raises_if_listing_doesnt_show_all_new_lines_in_diff (line 150) | def test_raises_if_listing_doesnt_show_all_new_lines_in_diff(self):
method test_raises_if_listing_lines_in_wrong_order (line 164) | def test_raises_if_listing_lines_in_wrong_order(self):
method test_line_ordering_check_isnt_confused_by_dupe_lines (line 179) | def test_line_ordering_check_isnt_confused_by_dupe_lines(self):
method test_line_ordering_check_isnt_confused_by_new_lines_that_dupe_existing (line 198) | def test_line_ordering_check_isnt_confused_by_new_lines_that_dupe_exis...
method DONTtest_non_dupes_are_still_order_checked (line 219) | def DONTtest_non_dupes_are_still_order_checked(self):
method test_raises_if_any_other_listing_lines_not_in_before_version (line 242) | def test_raises_if_any_other_listing_lines_not_in_before_version(self):
method test_happy_with_lines_in_before_and_after_version (line 258) | def test_happy_with_lines_in_before_and_after_version(self):
method test_raises_if_listing_line_not_in_after_version (line 278) | def test_raises_if_listing_line_not_in_after_version(self):
method test_happy_with_lines_from_just_before_diff (line 296) | def test_happy_with_lines_from_just_before_diff(self):
method test_listings_showing_a_move_mean_can_ignore_commit_lines_added_and_removed (line 311) | def test_listings_showing_a_move_mean_can_ignore_commit_lines_added_an...
method test_listings_showing_a_move_mean_can_ignore_commit_lines_added_and_removed_2 (line 332) | def test_listings_showing_a_move_mean_can_ignore_commit_lines_added_an...
method test_happy_with_elipsis (line 350) | def test_happy_with_elipsis(self):
method DONTtest_listings_must_use_elipsis_to_indicate_skipped_lines (line 365) | def DONTtest_listings_must_use_elipsis_to_indicate_skipped_lines(self):
method test_happy_with_python_callouts (line 390) | def test_happy_with_python_callouts(self):
method test_happy_with_js_callouts (line 405) | def test_happy_with_js_callouts(self):
method test_happy_with_blank_lines (line 420) | def test_happy_with_blank_lines(self):
method test_handles_indents (line 436) | def test_handles_indents(self):
method test_over_indentation_differences_are_picked_up (line 454) | def test_over_indentation_differences_are_picked_up(self):
method test_under_indentation_differences_are_picked_up (line 473) | def test_under_indentation_differences_are_picked_up(self):
method test_with_diff_listing_passing_case (line 492) | def test_with_diff_listing_passing_case(self):
method test_with_diff_listing_failure_case (line 516) | def test_with_diff_listing_failure_case(self):
class SourceTreeRunCommandTest (line 543) | class SourceTreeRunCommandTest(unittest.TestCase):
method test_running_simple_command (line 544) | def test_running_simple_command(self):
method test_default_directory_is_tempdir (line 549) | def test_default_directory_is_tempdir(self):
method test_returns_output (line 554) | def test_returns_output(self):
method test_raises_on_errors (line 559) | def test_raises_on_errors(self):
method test_environment_variables (line 567) | def test_environment_variables(self):
method test_doesnt_raise_for_some_things_where_a_return_code_is_ok (line 573) | def test_doesnt_raise_for_some_things_where_a_return_code_is_ok(self):
method test_cleanup_kills_backgrounded_processes_and_rmdirs (line 581) | def test_cleanup_kills_backgrounded_processes_and_rmdirs(self):
method test_running_interactive_command (line 615) | def test_running_interactive_command(self):
class CommitTest (line 625) | class CommitTest(unittest.TestCase):
method test_init_from_example (line 626) | def test_init_from_example(self):
class CheckChunksTest (line 729) | class CheckChunksTest(unittest.TestCase):
method test_get_offset_when_none (line 730) | def test_get_offset_when_none(self):
method test_get_offset_when_some (line 741) | def test_get_offset_when_some(self):
method test_passing_case (line 754) | def test_passing_case(self):
method test_over_indentation_differences_are_picked_up (line 772) | def test_over_indentation_differences_are_picked_up(self):
method test_under_indentation_differences_are_picked_up (line 791) | def test_under_indentation_differences_are_picked_up(self):
method test_leading_blank_lines_in_listing_are_ignored (line 810) | def test_leading_blank_lines_in_listing_are_ignored(self):
method test_thing (line 829) | def test_thing(self):
method test_trailing_blank_lines_in_listing_are_ignored (line 862) | def test_trailing_blank_lines_in_listing_are_ignored(self):
method test_elipsis_lines_are_ignored (line 881) | def test_elipsis_lines_are_ignored(self):
FILE: tests/test_write_to_file.py
class ClassFinderTest (line 17) | class ClassFinderTest(unittest.TestCase):
method test_find_last_line_for_class (line 19) | def test_find_last_line_for_class(self):
class LineFinderTest (line 48) | class LineFinderTest(unittest.TestCase):
method test_number_of_identical_chars (line 50) | def test_number_of_identical_chars(self):
class WriteToFileTest (line 70) | class WriteToFileTest(unittest.TestCase):
method setUp (line 73) | def setUp(self):
method tearDown (line 76) | def tearDown(self):
method test_simple_case (line 79) | def test_simple_case(self):
method test_multiple_files (line 87) | def test_multiple_files(self):
method assert_write_to_file_gives (line 97) | def assert_write_to_file_gives(
method test_strips_python_line_callouts_one_space (line 111) | def test_strips_python_line_callouts_one_space(self):
method test_strips_python_line_callouts_two_spaces (line 115) | def test_strips_python_line_callouts_two_spaces(self):
method test_strips_js_line_callouts (line 120) | def test_strips_js_line_callouts(self):
method test_doesnt_mess_with_multiple_newlines (line 127) | def test_doesnt_mess_with_multiple_newlines(self):
method test_existing_file_bears_no_relation_means_replaced (line 132) | def test_existing_file_bears_no_relation_means_replaced(self):
method test_existing_file_has_views_means_apppend (line 139) | def test_existing_file_has_views_means_apppend(self):
method test_existing_file_has_single_class_means_replace (line 170) | def test_existing_file_has_single_class_means_replace(self):
method test_existing_file_has_multiple_classes_means_append (line 190) | def test_existing_file_has_multiple_classes_means_append(self):
method test_leading_elipsis_is_ignored (line 235) | def test_leading_elipsis_is_ignored(self):
method test_adding_import_at_top_then_elipsis_then_modified_stuff (line 274) | def test_adding_import_at_top_then_elipsis_then_modified_stuff(self):
method DONTtest_adding_import_at_top_without_elipsis_then_modified_stuff (line 309) | def DONTtest_adding_import_at_top_without_elipsis_then_modified_stuff(...
method test_adding_import_at_top_then_elipsis_then_totally_new_stuff (line 350) | def test_adding_import_at_top_then_elipsis_then_totally_new_stuff(self):
method test_elipsis_indicating_which_class_to_add_new_method_to (line 387) | def test_elipsis_indicating_which_class_to_add_new_method_to(self):
method test_adding_import_at_top_sorts_alphabetically_respecting_django_and_locals (line 430) | def test_adding_import_at_top_sorts_alphabetically_respecting_django_a...
method test_with_new_contents_then_indented_elipsis_then_appendix (line 529) | def test_with_new_contents_then_indented_elipsis_then_appendix(self):
method test_for_existing_file_replaces_matching_lines (line 552) | def test_for_existing_file_replaces_matching_lines(self):
method test_for_existing_file_doesnt_swallow_whitespace (line 585) | def test_for_existing_file_doesnt_swallow_whitespace(self):
method test_longer_new_file_starts_replacing_from_first_different_line (line 629) | def test_longer_new_file_starts_replacing_from_first_different_line(se...
method test_changing_the_end_of_a_method (line 661) | def test_changing_the_end_of_a_method(self):
method test_for_existing_file_inserting_new_lines_between_comments (line 704) | def test_for_existing_file_inserting_new_lines_between_comments(self):
method test_with_single_line_replacement (line 757) | def test_with_single_line_replacement(self):
method test_with_single_line_replacement_finds_most_probable_line (line 784) | def test_with_single_line_replacement_finds_most_probable_line(self):
method test_with_single_line_assertion_replacement (line 811) | def test_with_single_line_assertion_replacement(self):
method test_with_single_line_assertion_replacement_finds_right_one (line 838) | def test_with_single_line_assertion_replacement_finds_right_one(self):
method test_with_single_line_assertion_replacement_real_views_example (line 871) | def test_with_single_line_assertion_replacement_real_views_example(self):
method test_changing_function_signature_and_stripping_comment (line 931) | def test_changing_function_signature_and_stripping_comment(self):
method test_with_two_elipsis_dedented_change (line 952) | def test_with_two_elipsis_dedented_change(self):
method test_indents_in_new_dont_confuse_things (line 989) | def test_indents_in_new_dont_confuse_things(self):
method test_double_indents_in_new_dont_confuse_things (line 1020) | def test_double_indents_in_new_dont_confuse_things(self):
FILE: tests/update_source_repo.py
function fetch_if_possible (line 13) | def fetch_if_possible(target_dir: Path):
function update_sources_for_chapter (line 35) | def update_sources_for_chapter(chapter, previous_chapter=None):
function checkout_testrepo_branches (line 86) | def checkout_testrepo_branches():
function main (line 92) | def main():
FILE: tests/write_to_file.py
function _replace_lines_from_to (line 14) | def _replace_lines_from_to(old_lines, new_lines, start_pos, end_pos):
function _get_function (line 30) | def _get_function(source, function_name):
function _replace_lines_from (line 42) | def _replace_lines_from(old_lines, new_lines, start_pos):
function _number_of_identical_chars_at_beginning (line 54) | def _number_of_identical_chars_at_beginning(string1, string2):
function number_of_identical_chars (line 62) | def number_of_identical_chars(string1, string2):
function _replace_single_line (line 71) | def _replace_single_line(old_lines, new_lines):
function _replace_lines_in (line 81) | def _replace_lines_in(old_lines, new_lines):
function add_import_and_new_lines (line 126) | def add_import_and_new_lines(new_lines, old_lines):
function _find_last_line_for_class (line 139) | def _find_last_line_for_class(source, classname):
function add_to_class (line 149) | def add_to_class(new_lines, old_lines):
function write_to_file (line 158) | def write_to_file(codelisting, cwd):
function _write_to_file (line 172) | def _write_to_file(path, new_contents):
Condensed preview — 209 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (3,334K chars).
[
{
"path": "!README_FOR_PRODUCTION.txt",
"chars": 191,
"preview": "Note for production editor about page-breaking: the author has requested a global solution to keep code blocks from brea"
},
{
"path": ".dockerignore",
"chars": 6,
"preview": ".venv\n"
},
{
"path": ".git-blame-ignore-revs",
"chars": 156,
"preview": "# matt hacker bulk edit for prod\n2cb0ed8c264cee682303288ba5a5cea80956fb8d\n2735e383f1281c5f200e64cfb7cda0457cfe8d1e\n6fce7"
},
{
"path": ".github/workflows/tests.yml",
"chars": 5171,
"preview": "---\nname: Book tests\n\non:\n schedule:\n - cron: \"45 15 * * *\"\n push:\n branches:\n main\n pull_request:\n\njobs:\n"
},
{
"path": ".gitignore",
"chars": 770,
"preview": "*.pyc\n.cache\n.vagrant\n*-cloudimg-console.log\n.venv\n.pytest_cache\n\n/chapter_*.html\n/appendix_*.html\n/part*.html\n/outline_"
},
{
"path": ".gitmodules",
"chars": 4721,
"preview": "[submodule \"source/chapter_01/superlists\"]\n\tpath = source/chapter_01/superlists\n\turl = git@github.com:hjwp/book-example."
},
{
"path": ".python-version",
"chars": 5,
"preview": "3.14\n"
},
{
"path": "CITATION.md",
"chars": 300,
"preview": "Bibtex:\n```TeX\n@BOOK{percival:tdd:python,\n AUTHOR = \"{Harry J.W.} Percival\",\n TITLE = \"Test-Driven De"
},
{
"path": "Dockerfile",
"chars": 894,
"preview": "FROM python:slim\n\n# -- WIP --\n# this dockerfile is a work in progress,\n# the vague intention is to use it for CI.\n\n# RUN"
},
{
"path": "ER_sampleTOC.html",
"chars": 3343,
"preview": "<section data-type=\"preface\" xmlns=\"http://www.w3.org/1999/xhtml\">\n<h1>Brief Table of Contents (<em>Not Yet Final</em>)<"
},
{
"path": "LICENSE.md",
"chars": 235,
"preview": "This book, and all the associated source files, are being made available under\nthe Creative Commons Attribution-NonComme"
},
{
"path": "Makefile",
"chars": 8590,
"preview": "SHELL := /bin/bash\n\nSOURCES := $(wildcard *.asciidoc)\nHTML_PAGES := $(patsubst %.asciidoc, %.html, ${SOURCES})\nTESTS := "
},
{
"path": "README.md",
"chars": 5054,
"preview": "# Test-Driven Web Development With Python, the book.\n\n# License\n\nThe sources for this book are published under the Creat"
},
{
"path": "Vagrantfile",
"chars": 4532,
"preview": "# -*- mode: ruby -*-\n# vi: set ft=ruby :\n\n# All Vagrant configuration is done below. The \"2\" in Vagrant.configure\n# conf"
},
{
"path": "acknowledgments.asciidoc",
"chars": 7356,
"preview": "[preface]\n== Acknowledgments\n\nLots of people to thank, without whom this book would never have happened,\nand/or would ha"
},
{
"path": "ai_preface.asciidoc",
"chars": 6445,
"preview": "[[ai_preface]]\n[preface]\n== Preface to the Third Edition: [.keep-together]#TDD in the Age of AI#\n\nIs there any point in "
},
{
"path": "analytics.html",
"chars": 439,
"preview": "<html><head><script> (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ (i[r].q=i[r].q||[]"
},
{
"path": "appendix_CD.asciidoc",
"chars": 11475,
"preview": "[[appendix_CD]]\n[appendix]\n== Continuous Deployment (CD)\n\n.Warning\n*****************************************************"
},
{
"path": "appendix_DjangoRestFramework.asciidoc",
"chars": 18658,
"preview": "[[appendix_DjangoRestFramework]]\n[appendix]\nDjango-Rest-Framework\n---------------------\n\n\n\n\n(((\"Django-Rest-Framework (D"
},
{
"path": "appendix_Django_Class-Based_Views.asciidoc",
"chars": 19700,
"preview": "[[appendix_Django_Class-Based_Views]]\n[appendix]\nDjango Class-Based Views\n------------------------\n\n(((\"Django framework"
},
{
"path": "appendix_IV_testing_migrations.asciidoc",
"chars": 9502,
"preview": "[[data-migrations-appendix]]\n[appendix]\nTesting Database Migrations\n---------------------------\n\n\n\n(((\"database migratio"
},
{
"path": "appendix_IX_cheat_sheet.asciidoc",
"chars": 4077,
"preview": "[[cheat-sheet]]\n[appendix]\n== Cheat Sheet\n\nBy popular demand, this \"cheat sheet\" is loosely based on the recap/summary b"
},
{
"path": "appendix_X_what_to_do_next.asciidoc",
"chars": 5071,
"preview": "[[appendix4]]\n[appendix]\n== What to Do Next\n\n(((\"Test-Driven Development (TDD)\", \"future investigations\", id=\"TDDfuture3"
},
{
"path": "appendix_bdd.asciidoc",
"chars": 27314,
"preview": "[[appendix_bdd]]\n[appendix]\n== Behaviour-Driven Development (BDD) Tools\n\n\n.Warning, Content From Second Edition\n********"
},
{
"path": "appendix_fts_for_external_dependencies.asciidoc",
"chars": 19214,
"preview": "[[appendix_fts_for_external_dependencies]]\n[appendix]\n== The Subtleties of Functionally Testing External Dependencies\n\nY"
},
{
"path": "appendix_github_links.asciidoc",
"chars": 5919,
"preview": "[[appendix_github_links]]\n[appendix]\n== Source Code Examples\n\n(((\"code examples, obtaining and using\")))\nAll\nof the code"
},
{
"path": "appendix_logging.asciidoc",
"chars": 5458,
"preview": "[[appendix_logging]]\r\n[apendix]\r\nLogging\r\n~~~~~~~\r\n\r\nUsing Hierarchical Logging Config\r\n^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"
},
{
"path": "appendix_purist_unit_tests.asciidoc",
"chars": 58773,
"preview": "[[appendix_purist_unit_tests]]\n[appendix]\n== Test Isolation, and \"Listening to Your Tests\"\n\n\n.Warning, Appendix Not Upda"
},
{
"path": "appendix_rest_api.asciidoc",
"chars": 34485,
"preview": "[[appendix_rest_api]]\n[appendix]\nBuilding a REST API: JSON, Ajax, and Mocking with JavaScript\n--------------------------"
},
{
"path": "appendix_tradeoffs.asciidoc",
"chars": 1283,
"preview": "[[appendix_tradeoffs]]\n[appendix]\n== Testing Tradeoffs: Choosing the Right Place to Test From\n\npick up in chapter 16, re"
},
{
"path": "asciidoc.conf",
"chars": 94,
"preview": "[replacements]\n%1%=➊\n%2%=➋\n%3%=➌\n%4%=➍\n%5%=➎\n%6%=➏\n\n"
},
{
"path": "asciidoctor.css",
"chars": 30955,
"preview": "/* Asciidoctor default stylesheet | MIT License | http://asciidoctor.org */\n/* Remove comment around @import statement b"
},
{
"path": "atlas.json",
"chars": 2736,
"preview": "{\n \"branch\": \"main\",\n \"files\": [\n \"cover.html\",\n \"praise.html\",\n \"titlepage.html\",\n \"copyright.html\",\n "
},
{
"path": "author_bio.html",
"chars": 760,
"preview": "<section data-type=\"colophon\" xmlns=\"http://www.w3.org/1999/xhtml\" class=\"abouttheauthor\">\n <h1>About the Author</h1>\n "
},
{
"path": "bibliography.asciidoc",
"chars": 832,
"preview": "[role=\"bibliography\":\"]\n[appendix]\n\n== Bibliography\n\nA few books about TDD and software development that I've mentioned "
},
{
"path": "book.asciidoc",
"chars": 2170,
"preview": ":doctype: book\n:bookseries: pythonbook\n:source-highlighter: pygments\n:pygments-style: manni\n:icons: font\n\n= Test-Driven "
},
{
"path": "buy_the_book_banner.html",
"chars": 194,
"preview": "<div id=\"buy_the_book\" style=\"position: absolute; top: 0; right: 0; z-index:100\">\n <a href=\"/pages/book.html\">\n <img"
},
{
"path": "chapter_01.asciidoc",
"chars": 19648,
"preview": "[[chapter_01]]\n== Getting Django Set Up Using a [keep-together]#Functional Test#\n\nTest-driven development isn't somethin"
},
{
"path": "chapter_02_unittest.asciidoc",
"chars": 18153,
"preview": "[[chapter_02_unittest]]\n== Extending Our Functional Test Using [keep-together]#the unittest Module#\n\n(((\"functional test"
},
{
"path": "chapter_03_unit_test_first_view.asciidoc",
"chars": 33640,
"preview": "[[chapter_03_unit_test_first_view]]\n== Testing a Simple Home Page [keep-together]#with Unit Tests#\n\nWe finished the last"
},
{
"path": "chapter_04_philosophy_and_refactoring.asciidoc",
"chars": 37461,
"preview": "[[chapter_04_philosophy_and_refactoring]]\n== What Are We Doing with All These Tests? (And, Refactoring)\n\n(((\"Test-Driven"
},
{
"path": "chapter_05_post_and_database.asciidoc",
"chars": 73541,
"preview": "[[chapter_05_post_and_database]]\n== Saving User Input: Testing the Database\n\n// (((\"user interactions\", \"testing databas"
},
{
"path": "chapter_06_explicit_waits_1.asciidoc",
"chars": 19971,
"preview": "[[chapter_06_explicit_waits_1]]\n== Improving Functional Tests: Ensuring Isolation and Removing Magic Sleeps\n\nBefore we d"
},
{
"path": "chapter_07_working_incrementally.asciidoc",
"chars": 87893,
"preview": "[[chapter_07_working_incrementally]]\n== Working Incrementally\n\n(((\"Test-Driven Development (TDD)\", \"adapting existing co"
},
{
"path": "chapter_08_prettification.asciidoc",
"chars": 41826,
"preview": "[[chapter_08_prettification]]\n== Prettification: Layout and Styling, [.keep-together]#and What to Test About It#\n\n(((\"la"
},
{
"path": "chapter_09_docker.asciidoc",
"chars": 56451,
"preview": "[[chapter_09_docker]]\n== Containerization aka Docker\n\n[quote, Malvina Reynolds]\n________________________________________"
},
{
"path": "chapter_10_production_readiness.asciidoc",
"chars": 42243,
"preview": "[[chapter_10_production_readiness]]\n== Making Our App Production-Ready\n\nOur container is working fine but it's not produ"
},
{
"path": "chapter_11_server_prep.asciidoc",
"chars": 25319,
"preview": "[[chapter_11_server_prep]]\n== Getting a Server Ready for Deployment\n\n\n(((\"infrastructure as code (IaC)\")))\nThis chapter "
},
{
"path": "chapter_12_ansible.asciidoc",
"chars": 55856,
"preview": "[[chapter_12_ansible]]\n== Infrastructure as Code: Automated Deployments with Ansible\n\n[quote, 'Cay S. Horstmann']\n______"
},
{
"path": "chapter_13_organising_test_files.asciidoc",
"chars": 30149,
"preview": "[[chapter_13_organising_test_files]]\n== Splitting Our Tests into Multiple Files, [.keep-together]#and a Generic Wait Hel"
},
{
"path": "chapter_14_database_layer_validation.asciidoc",
"chars": 43864,
"preview": "[[chapter_14_database_layer_validation]]\n== Validation at the Database Layer\n\n(((\"user interactions\", \"validating inputs"
},
{
"path": "chapter_15_simple_form.asciidoc",
"chars": 63141,
"preview": "[[chapter_15_simple_form]]\n== A Simple Form\n\nAt the end of the last chapter,\nwe were left with the thought that there wa"
},
{
"path": "chapter_16_advanced_forms.asciidoc",
"chars": 59773,
"preview": "[[chapter_16_advanced_forms]]\n== More Advanced Forms\n\nLet's look at some more advanced forms usage.\nWe’ve successfully h"
},
{
"path": "chapter_17_javascript.asciidoc",
"chars": 58368,
"preview": "[[chapter_17_javascript]]\n== A Gentle Excursion into JavaScript\n\n[quote, Geoffrey Willans, English author and journalist"
},
{
"path": "chapter_18_second_deploy.asciidoc",
"chars": 9762,
"preview": "[[chapter_18_second_deploy]]\n== Deploying Our New Code\n\n(((\"deployment\", \"procedure for\", id=\"Dpro17\")))\nIt's time to de"
},
{
"path": "chapter_19_spiking_custom_auth.asciidoc",
"chars": 53165,
"preview": "[[chapter_19_spiking_custom_auth]]\n== User Authentication, Spiking, [keep-together]#and De-Spiking#\n\n(((\"authentication\""
},
{
"path": "chapter_20_mocking_1.asciidoc",
"chars": 46756,
"preview": "[[chapter_20_mocking_1]]\n== Using Mocks to Test External Dependencies\n\n(((\"Django framework\", \"sending emails\")))\n(((\"em"
},
{
"path": "chapter_21_mocking_2.asciidoc",
"chars": 51958,
"preview": "[[chapter_21_mocking_2]]\n== Using Mocks for Test Isolation\n\nIn this chapter, we'll finish up our login system.\nWhile doi"
},
{
"path": "chapter_22_fixtures_and_wait_decorator.asciidoc",
"chars": 20857,
"preview": "[[chapter_22_fixtures_and_wait_decorator]]\n== Test Fixtures and a Decorator [keep-together]#for Explicit Waits#\n\n(((\"aut"
},
{
"path": "chapter_23_debugging_prod.asciidoc",
"chars": 42187,
"preview": "[[chapter_23_debugging_prod]]\n== Debugging and Testing Server Issues\n\nPopping a few layers off the stack of things we're"
},
{
"path": "chapter_24_outside_in.asciidoc",
"chars": 44565,
"preview": "[[chapter_24_outside_in]]\n== Finishing \"My Lists\": Outside-In TDD\n\n(((\"Test-Driven Development (TDD)\", \"outside-in techn"
},
{
"path": "chapter_25_CI.asciidoc",
"chars": 51076,
"preview": "[[chapter_25_CI]]\n== CI: Continuous Integration\n\n(((\"continuous integration (CI)\", id=\"CI24\")))\n(((\"continuous integrati"
},
{
"path": "chapter_26_page_pattern.asciidoc",
"chars": 18446,
"preview": "[[chapter_26_page_pattern]]\n== The Token Social Bit, the Page Pattern, [.keep-together]#and an Exercise for the Reader#\n"
},
{
"path": "chapter_27_hot_lava.asciidoc",
"chars": 31005,
"preview": "[[chapter_27_hot_lava]]\n== Fast Tests, Slow Tests, and Hot Lava\n\n[quote, Casey Kinsey]\n_________________________________"
},
{
"path": "check-links.py",
"chars": 1396,
"preview": "#!python\n\nimport asyncio\nimport sys\nfrom pathlib import Path\n\nimport httpx\nfrom bs4 import BeautifulSoup\n\n\ndef find_link"
},
{
"path": "coderay-asciidoctor.css",
"chars": 3483,
"preview": "/*! Stylesheet for CodeRay to loosely match GitHub themes | MIT License */\npre.CodeRay{background:#f7f7f8}\n.CodeRay .lin"
},
{
"path": "colo.html",
"chars": 1969,
"preview": "<section id=\"colophon\" data-type=\"colophon\" xmlns=\"http://www.w3.org/1999/xhtml\">\n <h1>Colophon</h1>\n\n <p>The animal o"
},
{
"path": "copy_html_to_site_and_print_toc.py",
"chars": 6889,
"preview": "#!/usr/bin/env python\n\nimport re\nimport subprocess\nfrom collections.abc import Iterator\nfrom pathlib import Path\nfrom ty"
},
{
"path": "copyright.html",
"chars": 3908,
"preview": "<section data-type=\"copyright-page\">\n \n <!--TITLE-->\n <h1>Test-Driven Development with Python</h1>\n \n <!--AUTHO"
},
{
"path": "count-todos.py",
"chars": 588,
"preview": "import csv\nimport datetime\nimport re\nimport sys\nfrom pathlib import Path\n\nMARKERS = [\"TODO\", \"RITA\", \"DAVID\", \"SEBASTIAN"
},
{
"path": "cover.html",
"chars": 66,
"preview": "<figure data-type=\"cover\">\n<img src=\"images/cover.png\"/>\n</figure>"
},
{
"path": "disqus_comments.html",
"chars": 642,
"preview": "<div class=\"comments\" style=\"padding: 20px\">\n <h3>Comments</h3>\n <div id=\"disqus_thread\"></div>\n <script type=\"text/j"
},
{
"path": "docs/ORM_style_guide.htm",
"chars": 60626,
"preview": "<!DOCTYPE html>\r\n<html><head>\r\n<meta http-equiv=\"content-type\" content=\"text/html; charset=UTF-8\">\r\n\t\t\t<title>O'Reilly S"
},
{
"path": "docs/ORM_style_guide_files/main.css",
"chars": 4160,
"preview": "\nbody {\n font-family: \"Helvetica Neue\", Helvetica, Arial, Sans-Serif;\n font-size: 14px;\n color: #333333;\n ma"
},
{
"path": "docs/asciidoc-cheatsheet.html",
"chars": 43087,
"preview": "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">\n<html xmlns=\"http://ww"
},
{
"path": "docs/asciidoc-cheatsheet_files/Content.css",
"chars": 1509,
"preview": "/*\nShareMeNot is licensed under the MIT license:\nhttp://www.opensource.org/licenses/mit-license.php\n\n\nCopyright (c) 2012"
},
{
"path": "docs/asciidoc-cheatsheet_files/asciidoc.asc",
"chars": 44,
"preview": "Not found: /doc/javascripts/867/asciidoc.js\n"
},
{
"path": "docs/asciidoc-cheatsheet_files/asciidoc.css",
"chars": 9277,
"preview": "/* Shared CSS for AsciiDoc xhtml11 and html5 backends */\n\n/* Default font. */\nbody {\n font-family: Georgia,serif;\n}\n\n/*"
},
{
"path": "docs/asciidoc-cheatsheet_files/asciidoc.js",
"chars": 5826,
"preview": "var asciidoc = { // Namespace.\n\n/////////////////////////////////////////////////////////////////////\n// Table Of Conte"
},
{
"path": "docs/asciidoc-cheatsheet_files/jquery-1.js",
"chars": 45707,
"preview": "/*\n * jQuery 1.2 - New Wave Javascript\n *\n * Copyright (c) 2007 John Resig (jquery.com)\n * Dual licensed under the MIT ("
},
{
"path": "docs/asciidoc-cheatsheet_files/pygments.css",
"chars": 3906,
"preview": ".highlight { background: #f4f4f4; }\n.highlight .hll { background-color: #ffffcc }\n.highlight .c { color: #408080; font-"
},
{
"path": "docs/asciidoc-userguide.html",
"chars": 334681,
"preview": "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">\n<html xmlns=\"http://ww"
},
{
"path": "docs/asciidoc-userguide_files/Content.css",
"chars": 1509,
"preview": "/*\nShareMeNot is licensed under the MIT license:\nhttp://www.opensource.org/licenses/mit-license.php\n\n\nCopyright (c) 2012"
},
{
"path": "docs/asciidoc-userguide_files/asciidoc.css",
"chars": 9185,
"preview": "/* Shared CSS for AsciiDoc xhtml11 and html5 backends */\n\n/* Default font. */\nbody {\n font-family: Georgia,serif;\n}\n\n/*"
},
{
"path": "docs/asciidoc-userguide_files/asciidoc.js",
"chars": 5826,
"preview": "var asciidoc = { // Namespace.\n\n/////////////////////////////////////////////////////////////////////\n// Table Of Conte"
},
{
"path": "docs/asciidoc-userguide_files/layout2.css",
"chars": 1443,
"preview": "body {\n margin: 0;\n}\n\n#layout-menu-box {\n position: fixed;\n left: 0px;\n top: 0px;\n width: 160px;\n height: 100%;\n "
},
{
"path": "docs/example_book.txt",
"chars": 3708,
"preview": "Book Title Goes Here\n====================\nAuthor's Name\nv1.0, 2003-12\n:doctype: book\n\n\n[dedication]\nExample Dedication\n-"
},
{
"path": "epilogue.asciidoc",
"chars": 3590,
"preview": "[appendix]\n[role=\"afterword\"]\n== Obey the Testing Goat!\n\nLet's get back to the Testing Goat.\n\n\"Groan\", I hear you say—\"H"
},
{
"path": "index.txt",
"chars": 13416,
"preview": "Index\n\nA\nacceptance test (see functional tests/testing\n(FT))\nacceptance tests, 397\naesthetics (see layout and style)\nagi"
},
{
"path": "ix.html",
"chars": 28,
"preview": "<section data-type=\"index\"/>"
},
{
"path": "load_toc.js",
"chars": 714,
"preview": "var httpRequest = new XMLHttpRequest();\nhttpRequest.onreadystatechange = function() {\n if (httpRequest.readyState === X"
},
{
"path": "misc/chapters.rst",
"chars": 6293,
"preview": "===================================================\r\nPART 1 - An introduction to Test-Driven Development\r\n=============="
},
{
"path": "misc/chapters_v2.rst",
"chars": 6497,
"preview": "===================================================\r\nPART 1 - An introduction to Test-Driven Development\r\n=============="
},
{
"path": "misc/chimera_comments_scraper.py",
"chars": 3627,
"preview": "from __future__ import print_function\nfrom selenium import webdriver\nfrom selenium.webdriver.common.by import By\nfrom se"
},
{
"path": "misc/get_stats.py",
"chars": 3029,
"preview": "#!/usr/bin/env python3\nfrom collections import namedtuple\nimport csv\nfrom datetime import datetime\nimport os\nimport re\ni"
},
{
"path": "misc/get_stats.sh",
"chars": 48,
"preview": "dropbox stop\npython3 get_stats.py\ndropbox start\n"
},
{
"path": "misc/isolation-talks/djangoisland.md",
"chars": 2189,
"preview": "Outside-In TDD, Test Isolation, and Mocking\n===========================================\n\n* Harry Percival, @hjwp, www.ob"
},
{
"path": "misc/isolation-talks/djangoisland.py",
"chars": 313,
"preview": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n# models.py\r\nfrom django import models\r\n\r\nclass List(models.Mo"
},
{
"path": "misc/isolation-talks/extra_styling_for_djangoisland.css",
"chars": 169,
"preview": "body, a, em {\n color: white;\n}\nbody div.listingblock {\n margin-bottom: 100ex;\n}\np code, table code {\n color: whi"
},
{
"path": "misc/isolation-talks/outline.txt",
"chars": 4229,
"preview": "\n* 787a587 First real draft of My Lists FT. --ch18l001--\n* 9a0b007 do-nothing my lists link in navbar. --ch18l002-1--\n* "
},
{
"path": "misc/isolation-talks/webcast-commits.hist",
"chars": 4362,
"preview": "\n* 787a587 First real draft of My Lists FT. --ch18l001--\n* 9a0b007 do-nothing my lists link in navbar. --ch18l002-1--\n* "
},
{
"path": "misc/plot.py",
"chars": 1428,
"preview": "from datetime import datetime\nimport numpy\nfrom matplotlib import pyplot\nimport csv\n\ndef get_data_from_csv():\n with o"
},
{
"path": "misc/reddit_post.md",
"chars": 3723,
"preview": "Hi Python-Redditors!,\n\nI'm somehow writing a book on TDD and Python for O'Reilly, despite feeling massively underqualifi"
},
{
"path": "misc/redditnotesresponse.txt",
"chars": 4700,
"preview": "Thanks again for your detailed and very helpful comments!\n\nFirst off, on your general suggestion that Git and Deployment"
},
{
"path": "misc/tdd-flowchart.dot",
"chars": 476,
"preview": "digraph g {\n\nwrite_test [label=\"Write a test\" shape=box]\nif_passes [label=\"Run the test.\\nDoes it pass?\" shape=diamond]\n"
},
{
"path": "outline_and_future_chapters.asciidoc",
"chars": 4313,
"preview": "Outline to date & future chapters plan\n--------------------------------------\n\nThanks for reading this far! I'd really "
},
{
"path": "part1.asciidoc",
"chars": 1684,
"preview": "[[part1]]\n[part]\n[role=\"pagenumrestart\"]\n== The Basics of TDD and Django\n\n[partintro]\n--\nIn this first part, I'm going t"
},
{
"path": "part2.asciidoc",
"chars": 8080,
"preview": "[[part2]]\n[part]\n== Going to Production\n\n[partintro]\n[quote, 'https://oreil.ly/Q7UDe[DevOps Borat]']\n___________________"
},
{
"path": "part3.asciidoc",
"chars": 1229,
"preview": "[[part3]]\n[part]\n== Forms and Validation\n\n[partintro]\n--\nNow that we've got things into production,\nwe'll spend a bit of"
},
{
"path": "part4.asciidoc",
"chars": 2231,
"preview": "[[part4]]\n[part]\n== More Advanced Topics in Testing\n\n[partintro]\n--\n\n\"Oh my gosh, what? Another section? Harry, I'm ex"
},
{
"path": "praise.forbook.asciidoc",
"chars": 1362,
"preview": "[\"dedication\", role=\"praise\"]\r\n== Praise for 'Test-Driven Development with Python'\r\n\r\n[quote, Michael Foord, Python Core"
},
{
"path": "praise.html",
"chars": 1788,
"preview": "<section data-type=\"dedication\" xmlns=\"http://www.w3.org/1999/xhtml\" class=\"praise\">\n<h1>Praise for <em>Test-Driven Deve"
},
{
"path": "pre-requisite-installations.asciidoc",
"chars": 17095,
"preview": "[[pre-requisites]]\n[preface]\n== Prerequisites and Assumptions\n\n(((\"prerequisite knowledge\", id=\"prereq00\")))\n(((\"Test-Dr"
},
{
"path": "preface.asciidoc",
"chars": 15564,
"preview": "[[preface]]\n[preface]\n== Preface\n\nThis book has been my attempt to share with the world the journey\nI took from \"hacking"
},
{
"path": "pygments-default.css",
"chars": 4862,
"preview": "pre.pygments .hll { background-color: #ffffcc }\npre.pygments { background: #f8f8f8; }\npre.pygments .tok-c { color: #3D7B"
},
{
"path": "pyproject.toml",
"chars": 1258,
"preview": "[build-system]\nrequires = [\"setuptools\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\nrequires-python = \">=3.14\"\n\n[p"
},
{
"path": "rename-chapter.sh",
"chars": 663,
"preview": "#!/bin/bash\nset -eux\nset -o pipefail\n\nOLD_CHAPTER=$1\nNEW_NAME=$2\n\ngit mv \"$OLD_CHAPTER.asciidoc\" \"$NEW_NAME.asciidoc\"\nmv"
},
{
"path": "research/js-testing.rst",
"chars": 200,
"preview": "Options:\n\nQunit -- weird syntax, but seems popular\nYUI -- familiar\njasmine - seems sane\njs-test-driver: by google, but s"
},
{
"path": "run_test_tests.sh",
"chars": 235,
"preview": "#!/bin/bash\nPYTHONHASHSEED=0 pytest \\\n --failed-first \\\n --tb=short \\\n -k 'not test_listings_and_commands_and_o"
},
{
"path": "server-quickstart.md",
"chars": 4557,
"preview": "# Ultra-brief instructions for how to get a Linux server\n\nThese instructions are meant as companion to the \n[server prep"
},
{
"path": "source/blackify-chap.sh",
"chars": 548,
"preview": "#!/bin/bash\nset -e\n\nPREV=$1\nCHAP=$2\n\n# assumes a git remote local pointing at a local bare repo...\nREPO=local\n\ncd $CHAP/"
},
{
"path": "source/feed-thru-cherry-picks.sh",
"chars": 1495,
"preview": "#!/bin/bash\n# replay all the commits for a given chapter ($CHAP)\n# onto the latest version of the previous chapter ($PRE"
},
{
"path": "source/fix-commit-numbers.py",
"chars": 1549,
"preview": "#!/usr/bin/env python\n\n# use with\n# FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --msg-filter $PWD/../../fix-com"
},
{
"path": "source/push-back.sh",
"chars": 146,
"preview": "#!/bin/bash\nset -e\n\nCHAP=$1\n\ncd \"$CHAP/superlists\"\ngit push --force-with-lease local \"$CHAP\"\ngit push --force-with-lease"
},
{
"path": "tests/actual_manage_py_test.output",
"chars": 677051,
"preview": "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE"
},
{
"path": "tests/book_parser.py",
"chars": 7278,
"preview": "#!/usr/bin/env python3\nimport re\n\nCOMMIT_REF_FINDER = r\"ch\\d\\dl\\d\\d\\d-?\\d?\"\n\n\nclass CodeListing:\n COMMIT_REF_FINDER ="
},
{
"path": "tests/book_tester.py",
"chars": 38223,
"preview": "import os\nimport re\nimport subprocess\nimport sys\nimport tempfile\nimport time\nimport unittest\nfrom pathlib import Path\nfr"
},
{
"path": "tests/chapters.py",
"chars": 995,
"preview": "CHAPTERS = [\n # part 1\n \"chapter_01\",\n \"chapter_02_unittest\",\n \"chapter_03_unit_test_first_view\",\n \"chapt"
},
{
"path": "tests/check_links.py",
"chars": 454,
"preview": "from lxml import html\nimport requests\n\nwith open('book.html') as f:\n node = html.fromstring(f.read())\n\nall_hrefs = [e"
},
{
"path": "tests/conftest.py",
"chars": 59,
"preview": "import pytest\npytest.register_assert_rewrite('sourcetree')\n"
},
{
"path": "tests/examples.py",
"chars": 13263,
"preview": "CODE_LISTING_WITH_CAPTION = \"\"\"<div class=\"exampleblock sourcecode\">\n<div class=\"title\">functional_tests.py</div>\n<div c"
},
{
"path": "tests/my-phantomjs-qunit-runner.js",
"chars": 2374,
"preview": "/*global require, phantom */\nvar system = require('system');\n\nif (!system.args[1]){\n console.log('Pass path to test f"
},
{
"path": "tests/run-js-spec.py",
"chars": 2577,
"preview": "#!python\nimport re\nimport sys\nimport time\nfrom pathlib import Path\n\nfrom selenium import webdriver\nfrom selenium.webdriv"
},
{
"path": "tests/slimerjs-0.9.0/LICENSE",
"chars": 3708,
"preview": "The files SlimerJS are licensed under the MPL 2.0 (http://mozilla.org/MPL/2.0/),\nwith the exception of the sources file "
},
{
"path": "tests/slimerjs-0.9.0/README.md",
"chars": 2952,
"preview": "# SlimerJS\n\nSlimerJS is a scriptable browser. It allows you to manipulate a web page\nwith a Javascript script: opening a"
},
{
"path": "tests/slimerjs-0.9.0/application.ini",
"chars": 192,
"preview": "[App]\nVendor=Innophi\nName=SlimerJS\nVersion=0.9.0\nBuildID=20131211\nID=slimerjs@slimerjs.org\nCopyright=Copyright 2012-2013"
},
{
"path": "tests/slimerjs-0.9.0/slimerjs",
"chars": 7384,
"preview": "#!/bin/bash\n\n#retrieve full path of the current script\n# symlinks are resolved, so application.ini could be found\n# this"
},
{
"path": "tests/slimerjs-0.9.0/slimerjs.bat",
"chars": 7686,
"preview": "@echo off\r\n\r\nSET SLIMERJSLAUNCHER=\"%SLIMERJSLAUNCHER%\"\r\nREM % ~ d[rive] p[ath] 0[script name] is the absolute path to th"
},
{
"path": "tests/slimerjs-0.9.0/slimerjs.py",
"chars": 8418,
"preview": "#!/usr/bin/env python\n\nimport os\nimport sys\nimport tempfile\nimport shutil\nimport string\nimport subprocess\n\ndef resolve(p"
},
{
"path": "tests/source_updater.py",
"chars": 8224,
"preview": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nimport ast\nfrom collections import OrderedDict\nimport os\nimport re\nfrom t"
},
{
"path": "tests/sourcetree.py",
"chars": 11529,
"preview": "import io\nimport os\nimport re\nimport shutil\nimport signal\nimport subprocess\nimport tempfile\nimport time\nfrom dataclasses"
},
{
"path": "tests/test_appendix_DjangoRestFramework.py",
"chars": 1315,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass AppendixVIITest(ChapterTest):\n ch"
},
{
"path": "tests/test_appendix_Django_Class-Based_Views.py",
"chars": 1228,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass AppendixIITest(ChapterTest):\n cha"
},
{
"path": "tests/test_appendix_bdd.py",
"chars": 1288,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass AppendixVTest(ChapterTest):\n chap"
},
{
"path": "tests/test_appendix_purist_unit_tests.py",
"chars": 1317,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter20Test(ChapterTest):\n chap"
},
{
"path": "tests/test_appendix_rest_api.py",
"chars": 1210,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass AppendixVITest(ChapterTest):\n cha"
},
{
"path": "tests/test_book_parser.py",
"chars": 16332,
"preview": "#!/usr/bin/env python\nfrom lxml import html\nimport re\nfrom textwrap import dedent\nimport unittest\n\nfrom book_parser impo"
},
{
"path": "tests/test_book_tester.py",
"chars": 43615,
"preview": "import os\nimport shutil\nimport subprocess\nimport sys\nimport unittest\nfrom textwrap import dedent\nfrom unittest.mock impo"
},
{
"path": "tests/test_chapter_01.py",
"chars": 2161,
"preview": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nimport os\nimport unittest\n\nfrom book_parser import Output\nfrom book_teste"
},
{
"path": "tests/test_chapter_02_unittest.py",
"chars": 779,
"preview": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nimport unittest\n\nfrom book_tester import (\n ChapterTest,\n CodeListi"
},
{
"path": "tests/test_chapter_03_unit_test_first_view.py",
"chars": 1483,
"preview": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nimport unittest\nimport time\n\nfrom book_tester import (\n ChapterTest,\n "
},
{
"path": "tests/test_chapter_04_philosophy_and_refactoring.py",
"chars": 1130,
"preview": "#!/usr/bin/env python3\nimport time\nimport unittest\n\nfrom book_tester import (\n ChapterTest,\n CodeListing,\n Comm"
},
{
"path": "tests/test_chapter_05_post_and_database.py",
"chars": 1985,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import (\n ChapterTest,\n CodeListing,\n Command,\n Out"
},
{
"path": "tests/test_chapter_06_explicit_waits_1.py",
"chars": 1096,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import (\n ChapterTest,\n Command,\n)\n\n\nclass Chapter6Test(C"
},
{
"path": "tests/test_chapter_07_working_incrementally.py",
"chars": 1300,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import (\n ChapterTest,\n Command,\n)\n\n\nclass Chapter7Test(C"
},
{
"path": "tests/test_chapter_08_prettification.py",
"chars": 1386,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_parser import Command, Output\nfrom book_tester import ChapterTest\n\n\ncl"
},
{
"path": "tests/test_chapter_09_docker.py",
"chars": 1566,
"preview": "#!/usr/bin/env python3\nimport os\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter9Test(ChapterTest):"
},
{
"path": "tests/test_chapter_10_production_readiness.py",
"chars": 1483,
"preview": "#!/usr/bin/env python3\nimport unittest\nfrom pathlib import Path\n\nfrom book_tester import ChapterTest\n\nTHIS_DIR = Path(__"
},
{
"path": "tests/test_chapter_11_server_prep.py",
"chars": 1677,
"preview": "#!/usr/bin/env python3\nimport os\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter11Test(ChapterTest)"
},
{
"path": "tests/test_chapter_12_ansible.py",
"chars": 1619,
"preview": "#!/usr/bin/env python3\nimport os\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter12Test(ChapterTest)"
},
{
"path": "tests/test_chapter_13_organising_test_files.py",
"chars": 1057,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter13Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_14_database_layer_validation.py",
"chars": 1047,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter13Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_15_simple_form.py",
"chars": 1050,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter15Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_16_advanced_forms.py",
"chars": 1078,
"preview": "#!/usr/bin/env python3\nimport os\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter16Test(ChapterTest)"
},
{
"path": "tests/test_chapter_17_javascript.py",
"chars": 1230,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter16Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_19_spiking_custom_auth.py",
"chars": 1294,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter19Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_20_mocking_1.py",
"chars": 1271,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter20Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_21_mocking_2.py",
"chars": 1209,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter21Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_22_fixtures_and_wait_decorator.py",
"chars": 1133,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter22Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_23_debugging_prod.py",
"chars": 1922,
"preview": "#!/usr/bin/env python3.7\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter18Test(ChapterTest):\n ch"
},
{
"path": "tests/test_chapter_24_outside_in.py",
"chars": 1046,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter24Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_25_CI.py",
"chars": 1311,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter25Test(ChapterTest):\n chap"
},
{
"path": "tests/test_chapter_26_page_pattern.py",
"chars": 1371,
"preview": "#!/usr/bin/env python3\nimport unittest\n\nfrom book_tester import ChapterTest\n\n\nclass Chapter26Test(ChapterTest):\n chap"
},
{
"path": "tests/test_source_updater.py",
"chars": 17910,
"preview": "#!/usr/bin/env python3\nimport unittest\nimport tempfile\nfrom textwrap import dedent\n\n\nfrom source_updater import Source, "
},
{
"path": "tests/test_sourcetree.py",
"chars": 28185,
"preview": "import os\nimport subprocess\nimport unittest\nfrom textwrap import dedent\n\nimport pytest\nfrom book_parser import CodeListi"
},
{
"path": "tests/test_write_to_file.py",
"chars": 24662,
"preview": "#!/usr/bin/env python3\nimport unittest\nimport os\nimport shutil\nfrom textwrap import dedent\nimport tempfile\n\nfrom book_te"
},
{
"path": "tests/update_source_repo.py",
"chars": 3623,
"preview": "#!/usr/bin/env python\nimport getpass\nimport os\nimport subprocess\nfrom pathlib import Path\n\nfrom chapters import CHAPTERS"
},
{
"path": "tests/write_to_file.py",
"chars": 8620,
"preview": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nimport ast\nimport os\nimport re\nfrom textwrap import dedent\n\nfrom source_u"
},
{
"path": "theme/epub/epub.css",
"chars": 303,
"preview": "/* Styling for custom captions on code blocks */\n.sourcecode p {\n text-align: right;\n display: block;\n margin-bottom:"
},
{
"path": "theme/epub/epub.xsl",
"chars": 3147,
"preview": "<xsl:stylesheet version=\"1.0\"\n xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"\n xmlns:h=\""
},
{
"path": "theme/epub/layout.html",
"chars": 997,
"preview": "{{ doctype }}\n<html lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name"
},
{
"path": "theme/html/html.xsl",
"chars": 2371,
"preview": "<xsl:stylesheet version=\"1.0\"\n xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"\n xmlns:h=\""
},
{
"path": "theme/mobi/layout.html",
"chars": 863,
"preview": "{{ doctype }}\n<html lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name"
},
{
"path": "theme/mobi/mobi.css",
"chars": 303,
"preview": "/* Styling for custom captions on code blocks */\n.sourcecode p {\n text-align: right;\n display: block;\n margin-bottom:"
},
{
"path": "theme/mobi/mobi.xsl",
"chars": 1020,
"preview": "<xsl:stylesheet version=\"1.0\"\n xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"\n xmlns:h=\""
},
{
"path": "theme/pdf/pdf.css",
"chars": 6077,
"preview": "@charset \"UTF-8\";\n/*----Rendering for special role=\"caption\" lines below code blocks, per AU request; see RT #151714----"
},
{
"path": "theme/pdf/pdf.xsl",
"chars": 2067,
"preview": "<xsl:stylesheet version=\"1.0\"\n xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"\n xmlns:h=\""
},
{
"path": "titlepage.html",
"chars": 363,
"preview": "<section data-type=\"titlepage\" xmlns=\"http://www.w3.org/1999/xhtml\">\n<h1>Test-Driven Development <span class=\"keep-toget"
},
{
"path": "toc.html",
"chars": 59,
"preview": "<nav data-type=\"toc\" xmlns=\"http://www.w3.org/1999/xhtml\"/>"
},
{
"path": "todos.txt",
"chars": 1971,
"preview": "# Today\n\n\n## to consider\n\n- remove virtualenvs from diagram\n- dev-requirements.txt\n- assertEqual(items.objects.all, []) "
},
{
"path": "tools/figure_renaming_report.tsv",
"chars": 4145,
"preview": "Original names\tNew names\nimages/git_windows_installer_choose_editor.png\timages/ttd3_0001.png\nimages/python_install_add_t"
},
{
"path": "tools/intake_report.txt",
"chars": 4100,
"preview": "Title: Test-Driven Development with Python\nISBN: 9781098148713\nJIRA Ticket #: /DCPSPROD-10184\n\nStylesheet: animal_theme_"
},
{
"path": "tools/oneoffs/oneoff.css",
"chars": 2323,
"preview": "@charset \"UTF-8\";\n\n@import \"DYNAMIC CSS PLACEHOLDER\";\n\n/*----Rendering for special role=\"caption\" lines below code block"
},
{
"path": "tools/oneoffs/oneoff.xsl",
"chars": 4847,
"preview": "<?xml version=\"1.0\"?>\n\n<xsl:stylesheet version=\"1.0\"\n xmlns=\"http://www.w3.org/1999/xhtml\"\n "
},
{
"path": "video_plug.asciidoc",
"chars": 836,
"preview": "[[video_plug]]\n[preface]\n== Companion Video\n\n(((\"companion video\")))(((\"video-based instruction\")))(((\"Test-Driven Devel"
},
{
"path": "wordcount",
"chars": 1853,
"preview": " 76 721 4490 acknowledgments.asciidoc\n 606 2304 19065 appendix_II_Django_Class-Based_Views.asciidoc\n 213 "
},
{
"path": "workshops/intermediate_workshop_notes.md",
"chars": 15850,
"preview": "# Outline\n\n> 1 day: Outside-in TDD with and without mocks (AKA - \"listen to your tests\").\n> This day will start with a d"
},
{
"path": "workshops/js-testing-with-jasmine.asciidoc",
"chars": 15525,
"preview": "== Dipping Our Toes, Very Tentatively, into JavaScript\r\n\r\n\r\n[quote, 'John Calvin (as portrayed in http://onemillionpoint"
},
{
"path": "workshops/pycon.uk.2015.dirigible-talk.md",
"chars": 984,
"preview": "Title\nCategory\nDuration\nDescription\nIf your talk is accepted this will be made public and printed in the program. Should"
},
{
"path": "workshops/pycon.uk.2015.tutorial-beginners.md",
"chars": 4025,
"preview": "Title:\n TDD with Django, from scratch: a beginners intro to testing and web development\n\nCategory:\n Testing\n\nPytho"
}
]
// ... and 9 more files (download for full content)
About this extraction
This page contains the full source code of the hjwp/Book-TDD-Web-Dev-Python GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 209 files (3.0 MB), approximately 806.3k tokens, and a symbol index with 519 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.