Avoid Hardcoding ID’s in Your Tests2020-06-11
This is a test anti-pattern I’ve seen creep in on many Django test suites. I know of several large projects where it became a major undertaking to undo it. The good news is it’s easy to avoid adding it when you first write your tests. 🙂
Take this test:
It creates a book object in the database, retrieves the associated page, and checks the response looks correct.
In all likelihood this test will pass, at least initially. The test database should be empty at the start, the database will assign the new book ID 1, and when the test fetches the URL “/books/1/” the view finds the correct book.
Unfortunately, many potential changes to the project will break the ID = 1 assumption.
For example, if you create some “default books” in a
post_migrate signal handler (as I covered previously), the book in the test will be given a different ID.
Also, whilst databases tend to be predictable, they often don’t guarantee that future versions or configuration changes won’t ever affect ID generation.
The fix is relatively straightforward: stop hardcoding the ID in the URL, and instead template it. Python 3.6’s f-strings make this fluent. The above test would only need changing where it fetches the response:
This change is straightforward, and it clarifies where the number in the URL comes from.
This problem is also not specific to Django,
It can appear anywhere that you use a data generator in your tests, when you predict its output unnecessarily.
- other fields’ defaults.
- other database libraries such as SQL Alchemy.
- other kinds of data store.
Keep an eye out for it.
You can guard against this problem by making your
AutoField ID’s less predictable, each starting at a higher number than 1.
Using a different, large offset for each table can also prevent bugs where you mix up ID’s between models, or mix up list indices and ID’s.
You can change a table’s ID offset with an
The SQL is database specific:
- On MariaDB/MySQL, it’s
ALTER TABLE ... AUTO_INCREMENT=n.
- On PostgreSQL, it’s
ALTER SEQUENCE ... RESTART WITH n. You’ll need to figure out the sequence names for the tables though.
- On SQLite, I’m not sure - it looks a bit hairy. The best source I found was this Stack Overflow post which covers changing some internal tables.
- On Oracle, you’re on your own. I haven’t even researched it :)
If you want to do this for tests only, you can do it in a custom test runner. There you can override Django’s default test database creation and add extra behaviour. For example, for MariaDB/MySQL, and presuming you only have one database:
You can use this runner by setting the
Notes on the implementation:
It uses the undocumented
introspection.django_table_names()method to get the list of tables. Although a simple method, this might change between Django versions (tested on version 3.0).
For each table, a random offset is generated with
randint(). This is then used in an
ALTER TABLE ... AUTO_INCREMENT=query. The query doesn’t use the normal SQL escaping because the table name is already escaped with
startwill only be an integer.
I hope this helps you avoid and prevent this common test issue,
Are your Django project's tests slow? Read Speed Up Your Django Tests now!
One summary email a week, no spam, I pinky promise.
- My Most Used Pytest Commandline Flags
- Getting a Django Application to 100% Test Coverage
- Django's Test Case Classes and a Three Times Speed-Up
© 2020 All rights reserved.