diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 58744a98..d22a5a79 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,6 +45,7 @@ jobs: env: PY_COLORS: "1" # enable coloured output in pytest + EMAIL_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }} steps: - uses: actions/checkout@v4 diff --git a/appendix_fts_for_external_dependencies.asdiidoc b/appendix_fts_for_external_dependencies.asdiidoc new file mode 100644 index 00000000..67bea7f1 --- /dev/null +++ b/appendix_fts_for_external_dependencies.asdiidoc @@ -0,0 +1,589 @@ +[[appendix_fts_for_external_dependencies]] +[appendix] +== The Subtleties of Functionally Testing External Dependencies + +You might remember from <> +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 re-run 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 re-run 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:[rm $EMAIL_FILE_PATH/*] +---- + +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. + + +* TODO: recap diff --git a/atlas.json b/atlas.json index 42f7566e..a60c076e 100644 --- a/atlas.json +++ b/atlas.json @@ -44,6 +44,7 @@ "appendix_I_PythonAnywhere.asciidoc", "appendix_Django_Class-Based_Views.asciidoc", "appendix_IV_testing_migrations.asciidoc", + "appendix_fts_for_external_dependencies.asciidoc", "appendix_purist_unit_tests.asciidoc", "appendix_bdd.asciidoc", "appendix_rest_api.asciidoc", diff --git a/book.asciidoc b/book.asciidoc index a897f69b..1efbe0ed 100644 --- a/book.asciidoc +++ b/book.asciidoc @@ -57,6 +57,7 @@ include::epilogue.asciidoc[] include::appendix_I_PythonAnywhere.asciidoc[] include::appendix_Django_Class-Based_Views.asciidoc[] include::appendix_IV_testing_migrations.asciidoc[] +include::appendix_fts_for_external_dependencies.asciidoc[] include::appendix_purist_unit_tests.asciidoc[] include::appendix_bdd.asciidoc[] include::appendix_rest_api.asciidoc[] diff --git a/chapter_11_server_prep.asciidoc b/chapter_11_server_prep.asciidoc index dcf8a015..10e53e74 100644 --- a/chapter_11_server_prep.asciidoc +++ b/chapter_11_server_prep.asciidoc @@ -580,7 +580,7 @@ Here's the full command we'll use, with an explanation of each part: ansible-playbook \ --user=elspeth \ <1> -i staging.ottg.co.uk, \ <2><3> - infra/deploy-playbook.yaml.yaml \ <4> + infra/deploy-playbook.yaml \ <4> -vv <5> ---- @@ -610,7 +610,7 @@ Here's some example output when I run it: [subs="specialcharacters,macros"] ---- -$ pass:quotes[*ansible-playbook --user=elspeth -i staging.ottg.co.uk, infra/deploy-playbook.yaml.yaml -vv*] +$ pass:quotes[*ansible-playbook --user=elspeth -i staging.ottg.co.uk, infra/deploy-playbook.yaml -vv*] ansible-playbook [core 2.17.5] config file = None configured module search path = ['~/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules'] diff --git a/chapter_12_ansible.asciidoc b/chapter_12_ansible.asciidoc index 68d5ff15..7edb2397 100644 --- a/chapter_12_ansible.asciidoc +++ b/chapter_12_ansible.asciidoc @@ -1060,32 +1060,28 @@ Rewire the FT runner to be able to test against the local VM. Having a Vagrant config file is particularly helpful when working in a team--it helps new developers to spin up servers that look exactly like yours.((("", startref="ansible29"))) +//// -Deploying to Live -^^^^^^^^^^^^^^^^^ +=== Deploying to Prod -TODO update this So, let's try using it for our live site! [role="small-code against-server"] [subs=""] ---- -$ fab deploy:host=elspeth@superlists.ottg.co.uk +$ pass:quotes[*ansible-playbook --user=elspeth -i www.ottg.co.uk, infra/deploy-playbook.yaml -vv*] +[...] Done. -Disconnecting from elspeth@superlists.ottg.co.uk... done. +Disconnecting from elspeth@www.ottg.co.uk... done. ---- -'Brrp brrp brpp'. You can see the script follows a slightly different path, -doing a `git clone` to bring down a brand new repo instead of a `git pull`. -It also needs to set up a new virtualenv from scratch, including a fresh -install of pip and Django. The `collectstatic` actually creates new files this -time, and the `migrate` seems to have worked too. +_Brrp brrp brpp_. Looking good? Go take a click around your live site! @@ -1105,7 +1101,7 @@ $ *git tag LIVE* $ *export TAG=$(date +DEPLOYED-%F/%H%M)* # this generates a timestamp $ *echo $TAG* # should show "DEPLOYED-" and then the timestamp $ *git tag $TAG* -$ *git push origin LIVE $TAG* # pushes the tags up +$ *git push origin LIVE $TAG* # pushes the tags up to GitHub ---- Now it's easy, at any time, to check what the difference is @@ -1120,9 +1116,13 @@ $ *git log --graph --oneline --decorate* [...] ---- -//// +NOTE: Once again, this use of git tags isn't meant to be the One True Way. + We just need some sort of way of keeping track of what was deployed when. + + +=== Tell everyone! + -// RITA: Perhaps add the note about the reader emailing you when their site goes live to this point. "Tell your mum! Tell me! Email me at...."" You now have a live website! Tell all your friends! Tell your mum, if no one else is interested! Or, tell me! I'm always delighted to see a new reader's site! @@ -1133,7 +1133,7 @@ In the next chapter, it's back to coding again.((("", startref="Fstage11"))) // DAVID: maybe more of a conclusion here? It's quite a heavy chapter, // a bit of an anticlimax to stop here. I want some inspiring note to end on. // In particular, how does this tie into TDD? -// DAVID: Also - now it's on staging, should we release to prod too? + === Further Reading @@ -1145,20 +1145,13 @@ and lots, lots more to learn besides. Here are some resources I used for inspiration: -* https://12factor.net/[The 12-factor App] by the Heroku team +* The original https://12factor.net/[12-factor App] manifesto from the Heroku team -* http://hynek.me/talks/python-deployments[Solid Python Deployments for Everybody] by Hynek Schlawack -// CSANAD: the author suggests another, slightly more up-to date (from 2018) -// talk now: https://hynek.me/talks/deploy-friendly/ +* https://hynek.me/talks/deploy-friendly/[How to Write Deployment-Friendly Apps] by Hynek Schlawack * The deployment chapter of - https://www.feldroy.com/books/two-scoops-of-django-3-x[Two Scoops of Django] + https://www.feldroy.com/two-scoops-of-django[Two Scoops of Django] by Dan Greenfeld and Audrey Roy -// CSANAD: this is 404 now. The book no longer seems to have a separate page -// instead, they list all their books at -// https://www.feldroy.com/two-scoops-press - -] [role="pagebreak-before less_space"] diff --git a/chapter_18_second_deploy.asciidoc b/chapter_18_second_deploy.asciidoc index 02c7db6b..72a5ad10 100644 --- a/chapter_18_second_deploy.asciidoc +++ b/chapter_18_second_deploy.asciidoc @@ -3,15 +3,16 @@ ((("deployment", "procedure for", id="Dpro17"))) It's time to deploy our brilliant new validation code to our live servers. + This will be a chance to see our automated deploy scripts in action for the second time. +Let's take the opportunity to make a little deployment checklist. -// RITA: A long section where? In the book? Please clarify. NOTE: At this point I always want to say a huge thanks to Andrew Godwin and the whole Django team. - Up until Django 1.7, I used to have a whole long section, + In the first edition, I used to have a whole long section, entirely devoted to migrations. - Migrations now "just work", so I was able to drop it altogether. + Since Django 1.7, migrations now "just work", so I was able to drop it altogether. I mean yes this all happened nearly ten years ago, but still--open source software is a gift. We get such amazing things, entirely for free. @@ -28,11 +29,93 @@ You can refer back to <> for reminders on Ansible comman ******************************************************************************* +=== The Deployment Checklist + +Let's make a little checklist of pre-deployment tasks: + +1. We run all our unit and functional tests in the regular way. Just in case! +2. We rebuild our Docker image, and run our tests against Docker, on our local machine. +3. We deploy to staging, and run our FTs against staging. +4. Now we can deploy to prod. + +TIP: A deployment checklist like this should be a temporary measure. + Once you've worked through it manually a few times, + you should be looking to take the next step in automation, + continuous deployment straight using a CI/CD pipeline. + We'll touch on this in <>. + + + +=== A Full Test Run Locally + +Of course, under the watchful eye of the Testing Goat, +we're running the tests all the time! But, just in case: + +[subs="specialcharacters,quotes"] +---- +$ *cd src && python manage.py test* +[...] + +Ran 39 tests in 15.222s + +OK +---- + + +=== Quick Test Run Against Docker -=== Staging Deploy +The next step closer to prod, is running things in Docker. +This was one of the main reasons we went to the trouble of containerising our app, +which is being able to repro the production environment as faithfully as possible, +on our own machine. +So let's rebuild our Docker image and spin up a local Docker container: -We start with the staging server: + +[subs="specialcharacters,quotes"] +---- +$ *docker build -t superlists . && docker run \ + -p 8888:8888 \ + --mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \ + -e DJANGO_SECRET_KEY=sekrit \ + -e DJANGO_ALLOWED_HOST=localhost \ + -it superlists + => [internal] load build definition from Dockerfile 0.0s + => => transferring dockerfile: 371B 0.0s + => [internal] load metadata for docker.io/library/python:3.13-slim 1.4s + [...] + => => naming to docker.io/library/superlists 0.0s ++ docker run -p 8888:8888 --mount +type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 -e +DJANGO_SECRET_KEY=sekrit -e DJANGO_ALLOWED_HOST=localhost -e EMAIL_PASSWORD -it +superlists +[2025-01-27 21:29:37 +0000] [7] [INFO] Starting gunicorn 22.0.0 +[2025-01-27 21:29:37 +0000] [7] [INFO] Listening at: http://0.0.0.0:8888 (7) +[2025-01-27 21:29:37 +0000] [7] [INFO] Using worker: sync +[2025-01-27 21:29:37 +0000] [8] [INFO] Booting worker with pid: 8 +---- + +And now, in a separate terminal, we can run our FT suite against the Docker: + +[subs="specialcharacters,quotes"] +---- +$ *TEST_SERVER=localhost:8888 python src/manage.py test functional_tests* +[...] +...... + --------------------------------------------------------------------- +Ran 6 tests in 17.047s + +OK +---- + +Looking good! Let's move on to staging. + + + +=== Staging Deploy and Test Run + + +Here's our `ansible-playbook` command to deploy to staging: [role="against-server small-code"] [subs="specialcharacters,macros"] @@ -86,7 +169,7 @@ Disconnecting from staging.ottg.co.uk... done. ---- -And run the tests against staging: +And now we run the FTs against staging: [role="small-code"] [subs="specialcharacters,macros"] @@ -94,6 +177,7 @@ And run the tests against staging: $ pass:quotes[*TEST_SERVER=staging.ottg.co.uk python src/manage.py test functional_tests*] OK ---- + // CSANAD: I needed to add `force_source` to the "Import container image on // server" task. Otherwise, the server would deploy a container based on // the old image, even though a new one was successfully created locally (and @@ -118,12 +202,10 @@ OK Hooray! - [role="pagebreak-before less_space"] -=== Live Deploy +=== Production Deploy -// RITA: Forgive me if this is a newbie question, but will readers understand what you mean by "live"? As in, "live what"? Just making sure that it's clear. -Assuming all is well, we then run our deploy against live: +Since all is looking well we can deploy to prod! [role="against-server"] @@ -152,6 +234,7 @@ At this point you have two choices: 2. Learn about data migrations. See <>. + ==== How to Delete the Database on the Staging Server Here's how you might do option (1): diff --git a/chapter_19_spiking_custom_auth.asciidoc b/chapter_19_spiking_custom_auth.asciidoc index 868fa281..98931247 100644 --- a/chapter_19_spiking_custom_auth.asciidoc +++ b/chapter_19_spiking_custom_auth.asciidoc @@ -721,6 +721,9 @@ image::images/spike-it-worked-windows.png["screenshot of several windows includi TIP: If you get an `SMTPSenderRefused` error message, don't forget to set the `EMAIL_PASSWORD` environment variable in the shell that's running `runserver`. + Also, if you see a message saying "Application-specific password required.", + that's a Gmail security policy. Follow the link in the error message. + That's pretty much it! diff --git a/chapter_21_mocking_2.asciidoc b/chapter_21_mocking_2.asciidoc index 49200f13..489243b3 100644 --- a/chapter_21_mocking_2.asciidoc +++ b/chapter_21_mocking_2.asciidoc @@ -1497,12 +1497,12 @@ urlpatterns = [ And that gets us a fully passing FT--indeed, a fully passing test suite: -[subs="specialcharacters,macros"] +[subs="specialcharacters,quotes"] ---- -$ pass:quotes[*python src/manage.py test functional_tests.test_login*] +$ *python src/manage.py test functional_tests.test_login* [...] OK -$ pass:[cd src && python manage.py test] +$ *cd src && python manage.py test* [...] Ran 57 tests in 78.124s diff --git a/chapter_23_debugging_prod.asciidoc b/chapter_23_debugging_prod.asciidoc index 06626a11..2f9da3f7 100644 --- a/chapter_23_debugging_prod.asciidoc +++ b/chapter_23_debugging_prod.asciidoc @@ -1,13 +1,14 @@ [[chapter_23_debugging_prod]] -== Server-Side Debugging +== Debugging And Testing Production Issues -.Warning, Chapter Update in Progress +.Warning, Chapter Recently Updated ******************************************************************************* -🚧 Warning, Chapter update for 3e in progress. +This chapter has been recently updated to Django 5, Ansible+Docker, etc. -This chapter has only been partially updated to Django 5, Ansible+Docker, etc. +It all works on my machine, as they say! +Let me know if you run into anything strange. +Feedback and suggestions of any kind appreciated, as always. -Following along may be tricky, but I hope to have it in better shape soon! ******************************************************************************* @@ -15,89 +16,71 @@ Popping a few layers off the stack of things we're working on: we have nice wait-for helpers; what were we using them for? Oh yes, waiting to be logged in. And why was that? Ah yes, we had just built a way of pre-authenticating a user. +Let's see how that works against our staging server and Docker. -=== The Proof Is in the Pudding: Using Staging to Catch Final Bugs +=== The Proof Is in the Pudding: Using Docker to Catch Final Bugs -((("debugging", "server-side", "using staging sites", tertiary-sortas="staging sites", id="DBserstag21"))) -((("staging sites", "catching final bugs with", id="SScatch21"))) -They're all very well for running the FTs locally, -but how would they work against the staging server? -Let's try to deploy our site. -Along the way we'll catch an unexpected bug (that's what staging is for!), -and then we'll have to figure out a way of managing the database on the test server: +Remember the deployment checklist from <>? +Let's see if it can't come in useful today! + +First we rebuild and start our Docker container locally, +on port 8888: -[role="against-server small-code"] [subs="specialcharacters,quotes"] ---- -$ pass:quotes[*ansible-playbook --user=elspeth -i staging.ottg.co.uk, infra/deploy-playbook.yaml.yaml -vv*] - -PLAYBOOK: deploy-playbook.yaml.yaml *********************************************** -1 plays in infra/deploy-playbook.yaml.yaml +$ *docker build -t superlists . && docker run \ + -p 8888:8888 \ + --mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \ + -e DJANGO_SECRET_KEY=sekrit \ + -e DJANGO_ALLOWED_HOST=localhost \ + -it superlists* [...] + => => naming to docker.io/library/superlists [...] +[2025-01-27 22:37:02 +0000] [7] [INFO] Starting gunicorn 22.0.0 +[2025-01-27 22:37:02 +0000] [7] [INFO] Listening at: http://0.0.0.0:8888 (7) +[2025-01-27 22:37:02 +0000] [7] [INFO] Using worker: sync +[2025-01-27 22:37:02 +0000] [8] [INFO] Booting worker with pid: 8 ---- -Here's what happens when we run the functional tests: +And now let's do an FT run: -[role="against-server small-code"] + +[role="small-code"] [subs="specialcharacters,macros"] ---- -$ pass:quotes[*TEST_SERVER=staging.ottg.co.uk python src/manage.py test functional_tests*] - -====================================================================== -ERROR: test_logged_in_users_lists_are_saved_as_my_lists (functional_tests.test_ -my_lists.MyListsTest.test_logged_in_users_lists_are_saved_as_my_lists) - --------------------------------------------------------------------- -Traceback (most recent call last): - File "...goat-book/functional_tests/test_my_lists.py", line 34, in -test_logged_in_users_lists_are_saved_as_my_lists - self.wait_to_be_logged_in(email) +$ pass:quotes[*TEST_SERVER=localhost:8888 python src/manage.py test functional_tests*] [...] selenium.common.exceptions.NoSuchElementException: Message: Unable to locate -element: Log out - - -====================================================================== -FAIL: test_login_using_magic_link (functional_tests.test_login.LoginTest) - --------------------------------------------------------------------- -Traceback (most recent call last): - File "...goat-book/functional_tests/test_login.py", line 22, in -test_login_using_magic_link - self.wait_for(lambda: self.assertIn( +element: #id_logout; [...] [...] AssertionError: 'Check your email' not found in 'Server Error (500)' - - --------------------------------------------------------------------- -Ran 8 tests in 68.602s - +[...] FAILED (failures=1, errors=1) - ---- -We can't log in--either with the real email system or with our -pre-authenticated session. Looks like our nice new authentication -system is crashing the server. - - -Let's practice a bit of server-side debugging! - - -* TODO: show we can actually repro this with Docker. +We can't log in--either with the real email system or with our pre-authenticated session. +Looks like our nice new authentication system is crashing when we run it in Docker. -// TODO: actually, does this obviate the whole need for running fts against the server? +Let's practice a bit of production debugging! -=== Inspecting Logs on the Server +=== Inspecting the Docker Container Logs ((("logging"))) ((("Gunicorn", "logging setup"))) -In order to track this problem down, -we need to get some logging information out of Django. +When Django fails with a 500 or "Unhandled Exception" and DEBUG is off, +it doesn't print the tracebacks to your web browser. +But it will send them to your logs instead. -First, make sure your 'settings.py' still contains the `LOGGING` -settings which will actually send stuff to the console: +.Check our Django LOGGING settings +******************************************************************************* + +It's worth double checking at this point that your _settings.py_ +still contains the `LOGGING` settings which will actually send stuff +to the console: [role="sourcecode currentcontents"] .src/superlists/settings.py @@ -117,298 +100,334 @@ LOGGING = { ---- ==== -Restart the Docker container again if necessary, +Restart the Docker container if necessary, and then either rerun the FT, or just try to log in manually. -While that happens, we watch the logs on the server with `docker logs -f`: +******************************************************************************* -[role="server-commands"] -[subs="specialcharacters,quotes"] ----- -elspeth@server:$ *docker logs -f superlists* ----- +If you switch to the terminal that's running your Docker image, +you should see the traceback printed out in there: -You should see an error like this: -[role="skipme small-code"] +[role="skipme"] [subs="specialcharacters,quotes"] ---- Internal Server Error: /accounts/send_login_email Traceback (most recent call last): - File "/home/elspeth/sites/staging.ottg.co.uk/.venv/lib/python3.7/[...] - response = wrapped_callback(request, *callback_args, **callback_kwargs) - File -"/home/elspeth/sites/staging.ottg.co.uk/accounts/views.py", line -20, in send_login_email - [email] [...] - self.connection.sendmail(from_email, recipients, message.as_bytes(linesep='\r\n')) - File "/usr/lib/python3.7/smtplib.py", line 862, in sendmail + File "/src/accounts/views.py", line 16, in send_login_email + send_mail( + ~~~~~~~~~^ + "Your login link for Superlists", + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ...<2 lines>... + [email], + ^^^^^^^^ + ) + ^ +[...] + self.connection.sendmail( + ~~~~~~~~~~~~~~~~~~~~~~~~^ + from_email, recipients, message.as_bytes(linesep="\r\n") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + File "/usr/local/lib/python3.13/smtplib.py", line 876, in sendmail raise SMTPSenderRefused(code, resp, from_addr) -smtplib.SMTPSenderRefused: (530, b'5.5.1 Authentication Required. Learn more -at\n5.5.1 https://support.google.com/mail/?p=WantAuthError [...] -- gsmtp', 'noreply@superlists') +smtplib.SMTPSenderRefused: (530, b'5.7.0 Authentication Required. [...] ---- -Hm, Gmail is refusing to send our emails, is it? Now why might that be? -Ah yes, we haven't told the server what our password is! -((("", startref="SScatch21")))((("", startref="DBserstag21"))) +That looks like a pretty good clue to what's going on. +((("", startref="Dockercatch21"))) -=== Another Environment Variable +Sure enough! Good to know our local Docker setup can repro the error on the server. -((("debugging", "server-side", "setting secret environment variables"))) -((("environment variables"))) -((("secret values"))) -Just as in <>, -the place we set environment variables on the server is in the _superlists.env_ file. -Let's change it manually, on the server, for a test: -* TODO: maybe use ansible straight away? Also, need to discuss secret storage locally. +=== Another Environment Variable In Docker + +So, Gmail is refusing to let us send emails, is it? +Now why might that be? "Authentication Required" you say? +Oh woops, we haven't told the server what our password is! + + +As you might remember from earlier chapters, +our _settings.py_ expects to get the email server password from an environment variable +named `EMAIL_PASSWORD`: + +[role="sourcecode currentcontents"] +.src/superlists/settings.py +==== +[source,python] +---- +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_PASSWORD") +---- +==== + + +Let's add this new environment variable to our local Docker container `run` +command: + +First, set your email password in your terminal if you need to: -[role="server-commands small-code"] +[role="skipme"] [subs="specialcharacters,quotes"] ---- -elspeth@server:$ *echo EMAIL_PASSWORD=yoursekritpasswordhere >> ~/superlists.env* -elspeth@server:$ *docker restart superlists* +$ *echo $EMAIL_PASSWORD* +# if that's empty, let's set it: +$ *export EMAIL_PASSWORD="yoursekritpasswordhere"* ---- -Now if we rerun our FTs, we see a change: +Now let's pass that env var thru to our docker container using one more `-e` flag, +this one fishing the env var out of the shell we're in: -[role="against-server small-code"] -[subs="specialcharacters,macros"] + +[subs="specialcharacters,quotes"] ---- -$ pass:quotes[*TEST_SERVER=staging.ottg.co.uk python src/manage.py test functional_tests*] +$ *docker build -t superlists . && docker run \ + -p 8888:8888 \ + --mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \ + -e DJANGO_SECRET_KEY=sekrit \ + -e DJANGO_ALLOWED_HOST=localhost \ + -e EMAIL_PASSWORD \ + -it superlists* +---- + +TIP: If you use `-e` without an `=something` argument, + it sets the env var inside Docker to the same value set in the current shell. + It's like saying `-e EMAIL_PASSWORD=$EMAIL_PASSWORD` + +And now we can rerun our FT again. +We'll narrow it down to just the `test_login` test since that's the main one that has a problem: + +[role="small-code"] +[subs="specialcharacters,macros"] +---- +$ pass:quotes[*TEST_SERVER=localhost:8888 python src/manage.py test functional_tests.test_login*] [...] +ERROR: test_login_using_magic_link +(functional_tests.test_login.LoginTest.test_login_using_magic_link) + --------------------------------------------------------------------- Traceback (most recent call last): - File "...goat-book/functional_tests/test_login.py", line 28, in + File "...goat-book/src/functional_tests/test_login.py", line 32, in test_login_using_magic_link - email = mail.outbox[0] -IndexError: list index out of range + email = mail.outbox.pop() +IndexError: pop from empty list +---- -[...] +Well, not a pass, but the tests do get a little further. +It looks like our server _can_ now send emails +(if you check the docker logs, you'll see there are no more errors) +But our FT is saying it can't see any emails appearing in `mail.outbox`. -selenium.common.exceptions.NoSuchElementException: Message: Unable to locate -element: Log out ----- +==== `mail.outbox` Won't Work Outside Django's Test Environment -The `my_lists` failure is still the same, but we have more information in our login test: -the FT gets further, and the site now looks like it's sending emails correctly -(and the server log no longer shows any errors), -but we can't check the email in the `mail.outbox`... +The reason is that `mail.outbox` is a local, in-memory variable in Django, +so that's only going to work when our tests and our server are running in the same process, +like they do with unit tests or with `LiveServerTestCase` FTs. +When we run against another process, be it Docker or an actual server, +we can't access the same `mail.outbox` variable. -=== Adapting Our FT to Be Able to Test Real Emails via POP3 +We need another technique if we want to actually inspect the emails +that the server sends, in our tests against Docker +(or later, against the staging server). -((("debugging", "server-side", "testing POP3 emails", id="DBservemail21"))) -((("Django framework", "sending emails", id="DJFemail21"))) -((("emails, sending from Django", id="email21"))) -Ah. That explains it. -Now that we're running against a real server rather than the `LiveServerTestCase`, -we can no longer inspect the local `django.mail.outbox` to see sent emails. +[[options-for-testing-real-email]] +=== Deciding How to Test "Real" Email Sending -First, we'll need to know, in our FTs, -whether we're running against the staging server or not. -Let's save the `staging_server` variable on `self` in _base.py_: +This is a point at which we have to explore some tradeoffs. +There are a few different ways we could test this: + +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 + https://docs.djangoproject.com/en/5.1/topics/email/#file-backend[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. + + +But let's lay out some of the pros + cons: + + +.Testing Strategy Tradeoffs +[cols="1,1,1"] +|======= +| Strategy | Pros | Cons +| End-to-end with POP3 | Maximally realistic, tests the whole system | Slow, fiddly, unreliable +| Email testing service eg Mailinator/Mailsac| As realistic as real POP3, with better APIs for testing| Slow, possibly expensive. Plus I don't want to endorse any particular commercial provider ;-) +| 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 this case, I'm going to suggest we go for the last option, +which is not to test email sending on the server or in Docker. + +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. + +I'm going to suggest we stick to using `mail.outbox` when we're running local tests, +and we configure our FTs to just check that Docker (or, later, the staging server) +_seems_ to be able to send email (in the sense of "not crashing") +and we can skip the bit where we check the email contents in our FT. +Remember, we also have unit tests for the email content! + +NOTE: I explore some of the difficulties involved in getting + these kinds of tests to work in <>, + so go check that out if this feels like a bit of a cop-out! + +Here's where we can put an early return in the FT: [role="sourcecode"] -.src/functional_tests/base.py (ch21l009) +.src/functional_tests/test_login.py (ch23l009) ==== [source,python] ---- - def setUp(self): - self.browser = webdriver.Firefox() - self.test_server = os.environ.get("TEST_SERVER") - if self.test_server: - self.live_server_url = "http://" + self.test_server + # A message appears telling her an email has been sent + self.wait_for( + lambda: self.assertIn( + "Check your email", + self.browser.find_element(By.CSS_SELECTOR, "body").text, + ) + ) + + if self.test_server: + # Testing real email sending from the server is not worth it. + return + + # She checks her email and finds a message + email = mail.outbox.pop() ---- ==== -Then we build a helper function that can retrieve a real email from a real POP3 -email server, using the horrifically tortuous Python standard library POP3 -client: +This test will still fail if you don't set `EMAIL_PASSWORD` to a valid value +in Docker or on the server, so that's good enough for now. + +Here's how we populate the `.test_server` attribute: + [role="sourcecode"] -.src/functional_tests/test_login.py (ch21l010) +.src/functional_tests/base.py (ch23l010) ==== [source,python] ---- -import os -import poplib -import re -import time -[...] - - def wait_for_email(self, test_email, subject): - if not self.test_server: - email = mail.outbox.pop() - self.assertIn(test_email, email.to) - self.assertEqual(email.subject, subject) - return email.body - - email_id = None - start = time.time() - inbox = poplib.POP3_SSL("pop.mail.yahoo.com") - try: - inbox.user(test_email) - inbox.pass_(os.environ["YAHOO_PASSWORD"]) - while time.time() - start < 60: - # 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() +class FunctionalTest(StaticLiveServerTestCase): + def setUp(self): + self.browser = webdriver.Firefox() + self.test_server = os.environ.get("TEST_SERVER") # <1> + if self.test_server: + self.live_server_url = "http://" + self.test_server ---- ==== -* TODO: consider not using POP3, maybe - https://docs.djangoproject.com/en/5.0/topics/email/#file-backend[file backend] instead. - discuss tradeoff of testing config - vs not needing to test that django can actually send emails +<1> We upgrade `test_server` to being an attribute on the test object, + so we can access it in various places in our tests. + We'll see this come in useful later too! -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 -`YAHOO_PASSWORD` environment variable in the console that's running the FT. -[subs="specialcharacters,quotes"] ----- -$ *echo YAHOO_PASSWORD=otheremailpasswordhere >> .env* -$ *set -a; source .env; set +a* ----- +And you can confirm that the FT will fail if you don't set `EMAIL_PASSWORD` in Docker. -And then we feed through the rest of the changes to the FT that are required -as a result. Firstly, populating a `test_email` variable, differently for -local and staging tests: +Now let's see if we can get our FTs to pass against the server: +=== Setting Secret Environment Variables on the Server -[role="sourcecode small-code"] -.src/functional_tests/test_login.py (ch21l011-1) +((("environment variables"))k) +((("secret values"))) +Just as in <>, +the place we set environment variables on the server is in the _superlists.env_ file. + +Let's add it to the template first: + + +[role="sourcecode"] +.infra/env.j2 (ch23l011) ==== -[source,diff] ----- -@@ -9,7 +9,6 @@ from selenium.webdriver.common.keys import Keys - - from .base import FunctionalTest - --TEST_EMAIL = "edith@example.com" - SUBJECT = "Your login link for Superlists" - - -@@ -34,7 +33,6 @@ class LoginTest(FunctionalTest): - 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) -@@ -49,9 +47,14 @@ class LoginTest(FunctionalTest): - # Edith goes to the awesome superlists site - # and notices a "Log in" section in the navbar for the first time - # It's telling her to enter her email address, so she does -+ if self.test_server: -+ test_email = "edith.testuser@yahoo.com" -+ else: -+ test_email = "edith@example.com" -+ - self.browser.get(self.live_server_url) - self.browser.find_element(By.CSS_SELECTOR, "input[name=email]").send_keys( -- TEST_EMAIL, Keys.ENTER -+ test_email, Keys.ENTER - ) +[source,python] +---- +DJANGO_DEBUG_FALSE=1 +DJANGO_SECRET_KEY={{ secret_key }} +DJANGO_ALLOWED_HOST={{ host }} +EMAIL_PASSWORD={{ email_password }} ---- ==== -And then modifications involving using that variable and calling our new helper -function: +and now we add the line to the ansible deploy playbook +that looks up EMAIL_PASSWORD in our local environment: -[role="sourcecode small-code"] -.src/functional_tests/test_login.py (ch21l011-2) + +[role="sourcecode dofirst=ch23l012-1"] +.infra/deploy-playbook.yaml (ch23l012) ==== -[source,diff] ----- -@@ -69,15 +69,13 @@ class LoginTest(FunctionalTest): - ) - - # She checks her email and finds a message -- email = mail.outbox[0] -- self.assertIn(TEST_EMAIL, email.to) -- self.assertEqual(email.subject, SUBJECT) -+ 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) -+ # It has a url link in it -+ self.assertIn("Use this link to log in", body) -+ url_search = re.search(r"http://.+/.+$", body) - if not url_search: -- self.fail(f"Could not find url in email body:\n{email.body}") -+ self.fail(f"Could not find url in email body:\n{body}") - url = url_search.group(0) - self.assertIn(self.live_server_url, url) - -@@ -85,10 +83,10 @@ class LoginTest(FunctionalTest): - self.browser.get(url) - - # she is logged in! -- self.wait_to_be_logged_in(email=TEST_EMAIL) -+ self.wait_to_be_logged_in(email=test_email) - - # Now she logs out - self.browser.find_element(By.LINK_TEXT, "Log out").click() - - # She is logged out -- self.wait_to_be_logged_out(email=TEST_EMAIL) -+ self.wait_to_be_logged_out(email=test_email) +[source,python] +---- + - name: Ensure .env file exists + ansible.builtin.template: + src: env.j2 + dest: ~/superlists.env + force: true # update file if contents changed + vars: + host: "{{ inventory_hostname }}" + secret_key: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters') }}" + email_password: "{{ lookup('env', 'EMAIL_PASSWORD') }}" <1> ---- ==== +<1> We use another call to `lookup()`, + this one with the `env` parameter, + which is equivalent to `os.environ.get()` in Python. + +// TODO: backport that force=true from ch23l012-1 + + +//// +TODO: sidebar on making the secret key only update if changed. + +- name: Check secret key already exists + shell: grep -c "SECRET_KEY" ~/superlists.env || true + register: secret_key_line_count + +- name: add secret key line if not already there + when: secret_key_line_count.stdout == "0" + lineinfile: + dest: ~/superlists.env + line: SECRET_KEY={{ secret_key }} + vars: + secret_key: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters') }}" -And, believe it or not, that'll actually work, and give us an FT -that can actually check for logins that work, involving real emails! +or bite the bullet and do it here? +//// + +=== Moving on to the next failure + +Now if we rerun our full set of FTs, we can move on to the next failure: + [role="against-server small-code"] [subs="specialcharacters,macros"] ---- -$ pass:quotes[*TEST_SERVER=staging.ottg.co.uk python src/manage.py test functional_tests.test_login*] -[...] -OK +$ pass:quotes[*TEST_SERVER=localhost:8888 python src/manage.py test functional_tests*] ---- -NOTE: I've just hacked this email-checking code together, - and it's currently pretty ugly and brittle - (one common problem is picking up the wrong email from a previous test run). - With some cleanup and a few more retry loops - it could grow into something more reliable. - Alternatively, services like _mailinator.com_ will give you throwaway email addresses - and an API to check them, for a small fee. - ((("", startref="email21"))) - ((("", startref="DJFemail21"))) - ((("", startref="DBservemail21"))) - - -=== Managing the Test Database on Staging - -((("debugging", "server-side", "managing test databases", id="DBservdatabase21"))) -((("staging sites", "managing test databases", id="SSmanag21"))) -((("database testing", "managing test databases", id="DTmanag21"))) -((("sessions, pre-creating"))) Now we can rerun our full FT suite and get to the next failure: our attempt to create pre-authenticated sessions doesn't work, so the "My Lists" test fails: @@ -417,42 +436,52 @@ so the "My Lists" test fails: [subs="specialcharacters,macros"] ---- $ pass:quotes[*TEST_SERVER=staging.ottg.co.uk python src/manage.py test functional_tests*] - +[...] ERROR: test_logged_in_users_lists_are_saved_as_my_lists -(functional_tests.test_my_lists.MyListsTest) +(functional_tests.test_my_lists.MyListsTest.test_logged_in_users_lists_are_saved_as_my_lists) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "...goat-book/src/functional_tests/test_my_lists.py", line 36, in +test_logged_in_users_lists_are_saved_as_my_lists + self.wait_to_be_logged_in(email) + ~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^ +[...] +selenium.common.exceptions.NoSuchElementException: Message: Unable to locate +element: #id_logout; [...] [...] -selenium.common.exceptions.TimeoutException: Message: Could not find element -with id id_logout. Page text was: -Superlists -Sign in -Start a new To-Do list + --------------------------------------------------------------------- -Ran 8 tests in 72.742s +Ran 8 tests in 30.087s FAILED (errors=1) ---- -It's because our test utility function `create_pre_authenticated_session` only -acts on the local database. Let's find out how our tests can manage the -database on the server. + + +It's because our test utility function `create_pre_authenticated_session()` +only acts on the local database. +Let's find out how our tests can manage the database on the server. ==== A Django Management Command to Create Sessions +We need a way to make changes to the database inside Docker, or on the server. +Essentially we want to run some code outside the context of the tests +(and the test database) and in the context of the server and its database. + ((("scripts, building standalone"))) -To do things on the server, we'll need to build a self-contained script -that can be run from the command line on the server, most probably via Fabric. +When trying to build a standalone script that works with Django +(i.e., can talk to the database and so on), +there are some fiddly issues you need to get right, +like setting the `DJANGO_SETTINGS_MODULE` environment variable, +and setting `sys.path` correctly. -When trying to build a standalone script that works with Django (i.e., can talk -to the database and so on), there are some fiddly issues you need to get right, -like setting the `DJANGO_SETTINGS_MODULE` environment variable, and getting -`sys.path` correctly. Instead of messing about with all that, Django lets you create your own "management commands" (commands you can run with `python manage.py`), which will do all that path mangling for you. They live in a folder called -'management/commands' inside your apps: +_management/commands_ inside your apps: [subs=""] ---- @@ -460,14 +489,13 @@ $ mkdir -p src/functional_tests/management/commands $ touch src/functional_tests/management/__init__.py $ touch src/functional_tests/management/commands/__init__.py ---- -//ch21l012-1 The boilerplate in a management command is a class that inherits from `django.core.management.BaseCommand`, and that defines a method called `handle`: [role="sourcecode"] -.src/functional_tests/management/commands/create_session.py (ch21l012) +.src/functional_tests/management/commands/create_session.py (ch23l014) ==== [source,python] ---- @@ -515,7 +543,7 @@ for it to recognise it as a real app that might have management commands as well as tests: [role="sourcecode"] -.src/superlists/settings.py (ch21l014) +.src/superlists/settings.py (ch23l015) ==== [source,python] ---- @@ -538,9 +566,10 @@ $ pass:quotes[*python src/manage.py create_session a@b.com*] qnslckvp2aga7tm6xuivyb0ob1akzzwl ---- -NOTE: If you see an error saying the `auth_user` table is missing, you may need - to run `manage.py migrate`. In case that doesn't work, delete the - _db.sqlite3_ file and run +migrate+ again, to get a clean slate. +NOTE: If you see an error saying the `auth_user` table is missing, + you may need to run `manage.py migrate`. + In case that doesn't work, delete the _db.sqlite3_ file + and run `migrate` again, to get a clean slate. ==== Getting the FT to Run the Management Command on the Server @@ -550,15 +579,15 @@ when we're on the local server, and make it run the management command on the staging server if we're on that: [role="sourcecode"] -.src/functional_tests/test_my_lists.py (ch21l016) +.src/functional_tests/test_my_lists.py (ch23l016) ==== [source,python] ---- from django.conf import settings from .base import FunctionalTest +from .container_commands import create_session_on_server from .management.commands.create_session import create_pre_authenticated_session -from .server_tools import create_session_on_server class MyListsTest(FunctionalTest): @@ -584,109 +613,94 @@ class MyListsTest(FunctionalTest): ==== -Let's also tweak _base.py_, to gather a bit more information -when we populate `self.test_server`: - - -[role="sourcecode"] -.src/functional_tests/base.py (ch21l017) -==== -[source,python] ----- -from .server_tools import reset_database #<1> -[...] - -class FunctionalTest(StaticLiveServerTestCase): - def setUp(self): - self.browser = webdriver.Firefox() - self.test_server = os.environ.get("TEST_SERVER") - if self.test_server: - self.live_server_url = "http://" + self.test_server - reset_database(self.test_server) #<1> ----- -==== - -<1> This will be our function to reset the server database in between each - test. We'll write that next, using Fabric. - -==== Using Fabric Directly from Python +==== Running Commands Using Docker Exec and (optionally) SSH -* TODO: rewrite for ansible. -((("Fabric", "using directly from Python"))) -Rather than using the `fab` command, Fabric provides an API that lets -you run Fabric server commands directly inline in your Python code. You -just need to let it know the "host string" you're connecting to: +You may remember `docker exec` from <>, it lets us run +commands inside a running Docker container. +That's fine for when we're running against the local Docker, +but when we're against the server, we need to SSH in first. +There's a bit of plumbing here, but I've tried to break things down into small chunks: [role="sourcecode"] -.src/functional_tests/server_tools.py (ch18l018) +.src/functional_tests/container_commands.py (ch23l018) ==== [source,python] ---- -from fabric.api import run -from fabric.context_managers import settings, shell_env +import subprocess +USER = "elspeth" -def _get_manage_dot_py(host): - return f"~/sites/{host}/.venv/bin/python ~/sites/{host}/manage.py" +def create_session_on_server(host, email): + return _exec_in_container( + host, ["/venv/bin/python", "/src/manage.py", "create_session", email] # <1> + ) -def reset_database(host): - manage_dot_py = _get_manage_dot_py(host) - with settings(host_string=f"elspeth@{host}"): # <1> - run(f"{manage_dot_py} flush --noinput") # <2> ----- -==== -<1> Here's the context manager that sets the host string, in the form - 'user@server-address' (I've hardcoded my server username, elspeth, so - adjust as necessary). +def _exec_in_container(host, commands): + if "localhost" in host: # <2> + return _exec_in_container_locally(commands) + else: + return _exec_in_container_on_server(host, commands) -<2> Then, once we're inside the context manager, we can just call - Fabric commands as if we're in a fabfile. +def _exec_in_container_locally(commands): + print(f"Running {commands} on inside local docker container") + return _run_commands(["docker", "exec", _get_container_id()] + commands) # <3> -For creating the session, we have a slightly more complex procedure, -because we need to extract the `SECRET_KEY` and other env vars from -the current running server, to be able to generate a session key that's -cryptographically valid for the server: +def _exec_in_container_on_server(host, commands): + print(f"Running {commands!r} on {host} inside docker container") + return _run_commands( + ["ssh", f"{USER}@{host}", "docker", "exec", "superlists"] + commands # <4> + ) -[role="sourcecode small-code"] -.src/functional_tests/server_tools.py (ch18l019) -==== -[source,python] ----- -def _get_server_env_vars(host): - env_lines = run(f"cat ~/sites/{host}/.env").splitlines() # <1> - return dict(l.split("=") for l in env_lines if l) +def _get_container_id(): + return subprocess.check_output( # <5> + ["docker", "ps", "-q", "--filter", "ancestor=superlists"] # <3> + ).strip() -def create_session_on_server(host, email): - manage_dot_py = _get_manage_dot_py(host) - with settings(host_string=f"elspeth@{host}"): - env_vars = _get_server_env_vars(host) - with shell_env(**env_vars): # <2> - session_key = run(f"{manage_dot_py} create_session {email}") # <3> - return session_key.strip() + +def _run_commands(commands): + process = subprocess.run( # <5> + commands, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + result = process.stdout.decode() + if process.returncode != 0: + raise Exception(result) + print(f"Result: {result!r}") + return result.strip() ---- ==== +<1> We invoke our management command with the path to the virtualenv python, + the `create_session` command name, and pass in the email we want to create a session for -<1> We extract and parse the server's current environment variables from the - _.env_ file... +<2> We dispatch to two slightly different ways of running a command inside a container, + with the assumption that a host that's on "localhost" is a local Docker container, + and the others are on the staging server. -<2> In order to use them in another fabric context manager, `shell_env`, - which sets the environment for the next command... +<3> To run a command on the local Docker container, we're going to use `docker exec`, + and we have a little extra hop first to get the correct container ID. -<3> Which is to run our `create_session` management command, which calls the - same `create_pre_authenticated_session` function, but on the server. +<4> To run a command on the Docker container that's on the staging server, + we still use `docker exec`, but we do it inside an SSH session. + In this case we don't need the container ID, because the container is always named "superlists'. +<5> Finally we use Python's `subprocess` module to actually run a command. + You can see a couple of different ways of running it here, + which differ based on how we're handing errors and output; + the details don't matter too much. ==== Recap: Creating Sessions Locally Versus Staging @@ -701,39 +715,79 @@ Perhaps a little ascii-art diagram will help: [role="skipme small-code"] ---- - +-----------------------------------+ +-------------------------------------+ | MyListsTest | --> | .management.commands.create_session | | .create_pre_authenticated_session | | .create_pre_authenticated_session | | (locally) | | (locally) | +-----------------------------------+ +-------------------------------------+ - ---- -===== Against staging: + +===== Against Docker locally: [role="skipme small-code"] ---- -+-----------------------------------+ +-------------------------------------+ -| MyListsTest | | .management.commands.create_session | -| .create_pre_authenticated_session | | .create_pre_authenticated_session | -| (locally) | | (on server) | -+-----------------------------------+ +-------------------------------------+ - | ^ - v | -+----------------------------+ +--------+ +------------------------------+ -| server_tools | --> | fabric | --> | ./manage.py create_session | -| .create_session_on_server | | "run" | | (on server, using .env) | -| (locally) | +--------+ +------------------------------+ ++-----------------------------------+ +-------------------------------------+ +| MyListsTest | | .management.commands.create_session | +| .create_pre_authenticated_session | | .create_pre_authenticated_session | +| (locally) | | (in Docker) | ++-----------------------------------+ +-------------------------------------+ + | ^ + v | ++----------------------------+ +-------------+ +----------------------------+ +| server_tools | --> | docker exec | --> | ./manage.py create_session | +| .create_session_on_server | +-------------+ | (in Docker) | +| (locally) | +----------------------------+ +----------------------------+ +---- +===== Against Docker locally: + +[role="skipme small-code"] ---- ++-----------------------------------+ +-------------------------------------+ +| MyListsTest | | .management.commands.create_session | +| .create_pre_authenticated_session | | .create_pre_authenticated_session | +| (locally) | | (on server) | ++-----------------------------------+ +-------------------------------------+ + | ^ + v | + ++----------------------------+ +-----+ +-------------+ +------------------------------+ +| server_tools | --> | ssh | -> | docker exec | --> | ./manage.py create_session | +| .create_session_on_server | +-----+ +-------------+ | (on server) | +| (locally) | +------------------------------+ ++----------------------------+ +---- + + -In any case, let's see if it works. First, locally, to check that we didn't -break anything: +.An Alternative For Managing Test Database Content: Talking Directly to the DB +********************************************************************** +An alternative way of managing database content inside Docker, +or on a server, would be to talk directly to the DB + +Since we're using SQLite, that involves writing to the file directly, +This can be fiddly to get right, because when we're running inside Django's +test runner, Django takes over test database creation, +so you end up having to write raw SQL and managing your connections to the database directly. + +There are also some tricky interactions with the filesystem mounts and Docker, +as well as needing to have the SECRET_KEY env var set to the same value as on the server. + +If we were using a "classic" database server like Postgres or MySQL, +we'd be able to talk directly to the database over its port, +and that's an approach I've used successfully in the past (see eg https://www.cosmicpython.com/book/chapter_02_repository.html#_inverting_the_dependency_orm_depends_on_model) +but it's still fiddly to get right and usually requires writing your own SQL. +********************************************************************** + + +=== Testing the Management Command + +In any case, let's see if it works. +First, locally, to check that we didn't break anything: -[role="dofirst-ch21l022"] [subs="specialcharacters,macros"] ---- $ pass:quotes[*python src/manage.py test functional_tests.test_my_lists*] @@ -742,7 +796,31 @@ OK ---- -Next, against the server. +Next, against Docker. Rebuild first: + +[subs="specialcharacters,quotes"] +---- +$ *docker build -t superlists . && docker run \ + -p 8888:8888 \ + --mount type=bind,source=./src/db.sqlite3,target=/src/db.sqlite3 \ + -e DJANGO_SECRET_KEY=sekrit \ + -e DJANGO_ALLOWED_HOST=localhost \ + -e EMAIL_PASSWORD \ + -it superlists* +---- + +And then we run the FT that uses our fixture, against Docker: + +[subs="specialcharacters,macros"] +---- +$ pass:quotes[*TEST_SERVER=localhost:8888 python src/manage.py test functional_tests.test_my_lists*] + +[...] +OK +---- + + +And now against the server. First, re-deploy to make sure our [role="against-server"] @@ -759,20 +837,17 @@ And now we run the test: ---- $ TEST_SERVER=staging.ottg.co.uk python src/manage.py test \ functional_tests.test_my_lists -[...] -[elspeth@staging.ottg.co.uk] run: -~/sites/staging.ottg.co.uk/.venv/bin/python -~/sites/staging.ottg.co.uk/manage.py flush --noinput -[...] -[elspeth@staging.ottg.co.uk] run: -~/sites/staging.ottg.co.uk/.venv/bin/python -~/sites/staging.ottg.co.uk/manage.py create_session edith@example.com -[...] +Found 1 test(s). +Creating test database for alias 'default'... +System check identified no issues (0 silenced). +Running '/venv/bin/python /src/manage.py create_session edith@example.com' on staging.ottg.co.uk inside docker container +Result: '7n032ogf179t2e7z3olv9ct7b3d4dmas\n' . --------------------------------------------------------------------- -Ran 1 test in 5.701s +Ran 1 test in 4.515s OK +Destroying test database for alias 'default'... ---- Looking good! We can rerun all the tests to make sure... @@ -792,17 +867,78 @@ OK Hooray! -NOTE: I've shown one way of managing the test database, but you could - experiment with others--for example, if you were using MySQL or Postgres, - you could open up an SSH tunnel to the server, and use port forwarding to - talk to the database directly. You could then amend `settings.DATABASES` - during FTs to talk to the tunnelled port. You'd still need some way of - pulling in the staging server environment variables though.((("", startref="DBservdatabase21")))((("", startref="SSmanag21")))((("", startref="DTmanag21"))) +=== Test Database Cleanup + +One more thing to be aware of: now that we're running against a real database, +we don't get cleanup for free any more. +If you try running the tests twice--locally or against Docker, +you'll run into this error: + +[subs="specialcharacters,macros"] +---- +$ pass:quotes[*TEST_SERVER=localhost:8888 python src/manage.py test functional_tests.test_my_lists*] +[...] +django.db.utils.IntegrityError: UNIQUE constraint failed: accounts_user.email +---- + +It's because the user we created the first time we ran the tests is still in the database. +When we're running against Django's test database, Django cleans up for us. +Let's try and emulate that when we're running against a real database: + + + + +[role="sourcecode"] +.src/functional_tests/container_commands.py (ch23l019) +==== +[source,python] +---- +def reset_database(host): + return _exec_in_container( + host, ["/venv/bin/python", "/src/manage.py", "flush", "--noinput"] + ) +---- +==== + + +And let's add the call to `reset_database()` in our base test `setUp()` method: + + +[role="sourcecode"] +.src/functional_tests/base.py (ch23l020) +==== +[source,python] +---- +from .container_commands import reset_database #<1> +[...] + +class FunctionalTest(StaticLiveServerTestCase): + def setUp(self): + self.browser = webdriver.Firefox() + self.test_server = os.environ.get("TEST_SERVER") + if self.test_server: + self.live_server_url = "http://" + self.test_server + reset_database(self.test_server) +---- +==== + + +If you try to run your tests again, you'll find they pass happily. + + +[role="dofirst-ch23l021"] +[subs="specialcharacters,macros"] +---- +$ pass:quotes[*TEST_SERVER=localhost:8888 python src/manage.py test functional_tests.test_my_lists*] +[...] + +OK +---- [role="pagebreak-before less_space"] -.Warning: Be Careful Not to Run Test Code Against the Live Server +.Warning: Be Careful Not to Run Test Code Against the Production Server! ******************************************************************************* ((("database testing", "safeguarding production databases"))) ((("production databases"))) @@ -823,44 +959,6 @@ in <>. LFMF. ******************************************************************************* -=== Updating our Deploy Script - -* TODO: ansible. - -((("debugging", "server-side", "baking in logging code"))) -Before we finish, let's update our deployment fabfile so that it can -automatically add the `EMAIL_PASSWORD` to the _.env_ file on the server: - - -[role="sourcecode skipme"] -.src/deploy_tools/fabfile.py (ch18l021) -==== -[source,python] ----- -import os -[...] - - -def _create_or_update_dotenv(): - append(".env", "DJANGO_DEBUG_FALSE=y") - append(".env", f"SITENAME={env.host}") - current_contents = run("cat .env") - if "DJANGO_SECRET_KEY" not in current_contents: - new_secret = "".join( - random.SystemRandom().choices("abcdefghijklmnopqrstuvwxyz0123456789", k=50) - ) - append(".env", f"DJANGO_SECRET_KEY={new_secret}") - email_password = os.environ["EMAIL_PASSWORD"] # <1> - append(".env", f"EMAIL_PASSWORD={email_password}") # <1> ----- -==== - -<1> We just add two lines at the end of the script which will essentially - copy the local `EMAIL_PASSWORD` environment variable up to the server's - _.env_ file. - - - === Wrap-Up Actually getting your new code up and running on a server always tends to @@ -884,8 +982,8 @@ the ability to save their lists on a "My Lists" page. Fixtures also have to work remotely:: `LiveServerTestCase` makes it easy to interact with the test database using the Django ORM for tests running locally. Interacting with the - database on the staging server is not so straightforward. One solution - is Fabric and Django management commands, as I've shown, but you should + database inside Docker is not so straightforward. One solution + is `docker exec` and Django management commands, as I've shown, but you should explore what works for you--SSH tunnels, for example. ((("fixtures", "staging and"))) ((("staging sites", "fixtures and"))) diff --git a/chapter_24_outside_in.asciidoc b/chapter_24_outside_in.asciidoc index 65302680..02374d87 100644 --- a/chapter_24_outside_in.asciidoc +++ b/chapter_24_outside_in.asciidoc @@ -40,8 +40,9 @@ such as views and templates, taking advantage of the new attribute, and then finally add URL routing to point to the new view. It feels comfortable because it means you're never working on a bit of code -that is dependent on something that hasn't yet been implemented. Each bit of -work on the inside is a solid foundation on which to build the next layer out. +that is dependent on something that hasn't yet been implemented. +Each bit of work on the inside is a solid foundation +on which to build the next layer out. But working inside-out like this also has some weaknesses. @@ -50,28 +51,31 @@ But working inside-out like this also has some weaknesses. ((("Outside-In TDD", "vs. inside-out", secondary-sortas="inside-out"))) ((("inside-out TDD"))) -The most obvious problem with inside-out is that it requires us to stray from a -TDD workflow. Our functional test's first failure might be due to missing URL -routing, but we decide to ignore that and go off adding attributes to our -database model objects instead. - -We might have ideas in our head about the new desired behaviour of our inner -layers like database models, and often these ideas will be pretty good, but -they are actually just speculation about what's really required, because -we haven't yet built the outer layers that will use them. - -One problem that can result is to build inner components that are more -general or more capable than we actually need, which is a waste of time, -and an added source of complexity for your project. Another common problem -is that you create inner components with an API which is convenient for their -own internal design, but which later turns out to be inappropriate for the -calls your outer layers would like to make...worse still, you might end up -with inner components which, you later realise, don't actually solve the -problem that your outer layers need solved. - -In contrast, working outside-in allows you to use each layer to imagine the -most convenient API you could want from the layer beneath it. Let's see it in -action. +The most obvious problem with inside-out is that +it requires us to stray from a TDD workflow. +Our functional test's first failure might be due to missing URL routing, +but we decide to ignore that +and go off adding attributes to our database model objects instead. + +We might have ideas in our head +about the new desired behaviour of our inner layers like database models, +and often these ideas will be pretty good, +but they are actually just speculation about what's really required, +because we haven't yet built the outer layers that will use them. + +One problem that can result is to build inner components that are more general +or more capable than we actually need, which is a waste of time, +and an added source of complexity for your project. +Another common problem is that you create inner components +with an API which is convenient for their own internal design, +but which later turns out to be inappropriate +for the calls your outer layers would like to make...worse still, +you might end up with inner components which, you later realise, +don't actually solve the problem that your outer layers need solved. + +In contrast, working outside-in allows you to use each layer +to imagine the most convenient API you could want from the layer beneath it. +Let's see it in action. === The FT for "My Lists" diff --git a/pyproject.toml b/pyproject.toml index b6cc8b45..037a6a0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,3 +55,5 @@ ignore = [ # these help pyright in neovide to find its way around venvPath = "." venv = ".venv" +# most of the source for the book itself is untyped +typeCheckingMode = "standard" diff --git a/source/chapter_23_debugging_prod/superlists b/source/chapter_23_debugging_prod/superlists index 829023f0..93fb750a 160000 --- a/source/chapter_23_debugging_prod/superlists +++ b/source/chapter_23_debugging_prod/superlists @@ -1 +1 @@ -Subproject commit 829023f0ab4045b08acd7ebfde1067a0d8a21ff5 +Subproject commit 93fb750ae32a787c973f76582013c48abd6635cd diff --git a/tests/test_chapter_23_debugging_prod.py b/tests/test_chapter_23_debugging_prod.py index 8ad9c6ff..605dfb49 100644 --- a/tests/test_chapter_23_debugging_prod.py +++ b/tests/test_chapter_23_debugging_prod.py @@ -13,11 +13,12 @@ def test_listings_and_commands_and_output(self): self.parse_listings() # sanity checks - self.assertEqual(self.listings[0].type, "against staging") + self.assertEqual(self.listings[0].type, "docker run tty") self.assertEqual(self.listings[1].type, "output") # skips - # self.skip_with_check(45, "commit changes first") + self.skip_with_check(1, "naming to docker") + if DO_SERVER_COMMANDS: self.replace_command_with_check( 13, @@ -36,9 +37,9 @@ def test_listings_and_commands_and_output(self): # hack fast-forward if os.environ.get("SKIP"): - self.pos = 10 + self.pos = 14 self.sourcetree.run_command( - "git switch {}".format(self.sourcetree.get_commit_spec("ch17l004")) + "git checkout {}".format(self.sourcetree.get_commit_spec("ch23l009")) ) # if DO_SERVER_COMMANDS: diff --git a/todos.txt b/todos.txt index 51f4d719..5d349653 100644 --- a/todos.txt +++ b/todos.txt @@ -3,6 +3,14 @@ # Later +## switch to postgres + +- do it in deploy chaps +- production-readiness really +- install it locally? +- or, put it in a docker container? +- docker-compose?? + - consider splitting 23 into two chapters - figure out how to delete at least one FT? - spike chap: start with test of login view.