Extend CI and local test coverage to MySQL and SQLite (#744)

* Add test runner and improve test with multi databases

* Enhance cross-database compatibility and testing

- Fix TRUNCATE command support detection for different databases
- Add conditional PostgreSQL-specific model registration
- Improve database-specific test skipping logic
- Remove SQLite from TRUNCATE supported vendors list

* Add docker compose for testing

* Improve CI/CD with multi-database support

- Add separate test workflows for SQLite, PostgreSQL, and MySQL

* Add `mysqlclient` deps

* fix minor

- Add mysqlclient deps
- upload coverage step

* Fix coverage upload name conflicts in CI workflow

- Add database type to coverage upload names (SQLite/PostgreSQL/MySQL)
This commit is contained in:
Youngkwang Yang 2025-08-17 23:50:23 +09:00 committed by GitHub
parent 9ef8cf2476
commit 8003b069c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 385 additions and 116 deletions

View file

@ -0,0 +1,30 @@
name: 'Setup Python and Dependencies'
description: 'Common setup steps for Python and pip dependencies'
inputs:
python-version:
description: 'Python version to setup'
required: true
cache-key-prefix:
description: 'Prefix for pip cache key'
required: true
runs:
using: 'composite'
steps:
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ inputs.cache-key-prefix }}-${{ inputs.python-version }}-${{ hashFiles('**/pyproject.toml') }}
- name: Install Python dependencies
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions

View file

@ -3,67 +3,125 @@ name: Test
on: [push, pull_request] on: [push, pull_request]
jobs: jobs:
build: test-sqlite:
name: SQLite • Python ${{ matrix.python-version }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 5
matrix: matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: sqlite3
- name: Run tests
env:
TEST_DB_BACKEND: sqlite3
run: tox -v
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: SQLite • Python ${{ matrix.python-version }}
test-postgres:
name: PostgreSQL • Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
services: services:
postgres: postgres:
image: postgres:14 image: postgres:15
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres POSTGRES_DB: auditlog
ports: ports:
- 5432/tcp - 5432/tcp
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Setup Python and dependencies
uses: actions/setup-python@v5 uses: ./.github/actions/setup-python-deps
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache-key-prefix: postgresql
- name: Get pip cache dir - name: Run tests
id: pip-cache env:
run: | TEST_DB_BACKEND: postgresql
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT TEST_DB_HOST: localhost
TEST_DB_USER: postgres
TEST_DB_PASS: postgres
TEST_DB_NAME: auditlog
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
- name: Cache run: tox -v
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
-${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
restore-keys: |
-${{ matrix.python-version }}-v1-
- name: Install dependencies - name: Upload coverage
run: | uses: codecov/codecov-action@v5
python -m pip install --upgrade pip with:
python -m pip install --upgrade tox tox-gh-actions name: PostgreSQL • Python ${{ matrix.python-version }}
- name: Tox tests test-mysql:
run: | name: MySQL • Python ${{ matrix.python-version }}
tox -v runs-on: ubuntu-latest
env: strategy:
TEST_DB_HOST: localhost fail-fast: false
TEST_DB_USER: postgres matrix:
TEST_DB_PASS: postgres python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
TEST_DB_NAME: postgres services:
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }} mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: auditlog
MYSQL_USER: mysql
MYSQL_PASSWORD: mysql
MYSQL_ROOT_PASSWORD: mysql
ports:
- 3306/tcp
options: >-
--health-cmd="sh -c 'export MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\"; mysqladmin ping -h 127.0.0.1 --protocol=TCP -uroot --silent || exit 1'"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- uses: actions/checkout@v4
- name: Upload coverage - name: Install MySQL client libraries
uses: codecov/codecov-action@v5 run: |
with: sudo apt-get update
name: Python ${{ matrix.python-version }} sudo apt-get install -y libmysqlclient-dev pkg-config mysql-client
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: mysql
- name: Run tests
env:
TEST_DB_BACKEND: mysql
TEST_DB_HOST: 127.0.0.1
TEST_DB_USER: root
TEST_DB_PASS: mysql
TEST_DB_NAME: auditlog
TEST_DB_PORT: ${{ job.services.mysql.ports[3306] }}
run: tox -v
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: MySQL • Python ${{ matrix.python-version }}

View file

@ -83,7 +83,7 @@ class Command(BaseCommand):
class TruncateQuery: class TruncateQuery:
SUPPORTED_VENDORS = ("postgresql", "mysql", "sqlite", "oracle", "microsoft") SUPPORTED_VENDORS = ("postgresql", "mysql", "oracle", "microsoft")
@classmethod @classmethod
def support_truncate_statement(cls, database_vendor) -> bool: def support_truncate_statement(cls, database_vendor) -> bool:

View file

@ -0,0 +1,47 @@
services:
postgres:
container_name: auditlog_postgres
image: postgres:15
restart: "no"
environment:
POSTGRES_DB: auditlog
POSTGRES_USER: ${TEST_DB_USER}
POSTGRES_PASSWORD: ${TEST_DB_PASS}
ports:
- "${TEST_DB_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U ${TEST_DB_USER} -d auditlog
interval: 5s
timeout: 3s
retries: 5
mysql:
container_name: auditlog_mysql
platform: linux/x86_64
image: mysql:8.0
restart: "no"
environment:
MYSQL_DATABASE: auditlog
MYSQL_USER: ${TEST_DB_USER}
MYSQL_PASSWORD: ${TEST_DB_PASS}
MYSQL_ROOT_PASSWORD: ${TEST_DB_PASS}
ports:
- "${TEST_DB_PORT:-3306}:3306"
expose:
- '${TEST_DB_PORT:-3306}'
volumes:
- mysql-data:/var/lib/mysql
- ./docker/db/init-mysql.sh:/docker-entrypoint-initdb.d/init.sh
healthcheck:
test: mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS}
interval: 5s
timeout: 3s
retries: 3
volumes:
postgres-data:
driver: local
mysql-data:
driver: local

View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e
mysql -u root -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL
GRANT ALL PRIVILEGES ON test_auditlog.* to '$MYSQL_USER';
EOSQL

View file

@ -1,6 +1,6 @@
import uuid import uuid
from django.contrib.postgres.fields import ArrayField from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import models from django.db import models
@ -311,26 +311,36 @@ class CharfieldTextfieldModel(models.Model):
history = AuditlogHistoryField(delete_related=True) history = AuditlogHistoryField(delete_related=True)
class PostgresArrayFieldModel(models.Model): # Only define PostgreSQL-specific models when ArrayField is available
""" if settings.TEST_DB_BACKEND == "postgresql":
Test auditlog with Postgres's ArrayField from django.contrib.postgres.fields import ArrayField
"""
RED = "r" class PostgresArrayFieldModel(models.Model):
YELLOW = "y" """
GREEN = "g" Test auditlog with Postgres's ArrayField
"""
STATUS_CHOICES = ( RED = "r"
(RED, "Red"), YELLOW = "y"
(YELLOW, "Yellow"), GREEN = "g"
(GREEN, "Green"),
)
arrayfield = ArrayField( STATUS_CHOICES = (
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3 (RED, "Red"),
) (YELLOW, "Yellow"),
(GREEN, "Green"),
)
history = AuditlogHistoryField(delete_related=True) arrayfield = ArrayField(
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
)
history = AuditlogHistoryField(delete_related=True)
else:
class PostgresArrayFieldModel(models.Model):
class Meta:
managed = False
class NoDeleteHistoryModel(models.Model): class NoDeleteHistoryModel(models.Model):
@ -448,7 +458,8 @@ auditlog.register(AdditionalDataIncludedModel)
auditlog.register(DateTimeFieldModel) auditlog.register(DateTimeFieldModel)
auditlog.register(ChoicesFieldModel) auditlog.register(ChoicesFieldModel)
auditlog.register(CharfieldTextfieldModel) auditlog.register(CharfieldTextfieldModel)
auditlog.register(PostgresArrayFieldModel) if settings.TEST_DB_BACKEND == "postgresql":
auditlog.register(PostgresArrayFieldModel)
auditlog.register(NoDeleteHistoryModel) auditlog.register(NoDeleteHistoryModel)
auditlog.register(JSONModel) auditlog.register(JSONModel)
auditlog.register(NullableJSONModel) auditlog.register(NullableJSONModel)

View file

@ -6,9 +6,13 @@ from unittest import mock
import freezegun import freezegun
from django.core.management import call_command from django.core.management import call_command
from django.db import connection
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
from django.test.utils import skipIf
from test_app.models import SimpleModel from test_app.models import SimpleModel
from auditlog.management.commands.auditlogflush import TruncateQuery
class AuditlogFlushTest(TestCase): class AuditlogFlushTest(TestCase):
def setUp(self): def setUp(self):
@ -139,6 +143,10 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
) )
self.assertEqual(err, "", msg="No stderr") self.assertEqual(err, "", msg="No stderr")
@skipIf(
not TruncateQuery.support_truncate_statement(connection.vendor),
"Database does not support TRUNCATE",
)
def test_flush_with_truncate_and_yes(self): def test_flush_with_truncate_and_yes(self):
obj = self.make_object() obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.") self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
@ -152,6 +160,10 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
) )
self.assertEqual(err, "", msg="No stderr") self.assertEqual(err, "", msg="No stderr")
@skipIf(
not TruncateQuery.support_truncate_statement(connection.vendor),
"Database does not support TRUNCATE",
)
def test_flush_with_truncate_with_input_yes(self): def test_flush_with_truncate_with_input_yes(self):
obj = self.make_object() obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.") self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")

View file

@ -0,0 +1,51 @@
"""
PostgreSQL-specific tests for django-auditlog.
"""
from unittest import skipIf
from django.conf import settings
from django.test import TestCase
from test_app.models import PostgresArrayFieldModel
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
class PostgresArrayFieldModelTest(TestCase):
databases = "__all__"
def setUp(self):
self.obj = PostgresArrayFieldModel.objects.create(
arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN],
)
@property
def latest_array_change(self):
return self.obj.history.latest().changes_display_dict["arrayfield"][1]
def test_changes_display_dict_arrayfield(self):
self.assertEqual(
self.latest_array_change,
"Red, Green",
msg="The human readable text for the two choices, 'Red, Green' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
self.obj.arrayfield = []
self.obj.save()
self.assertEqual(
self.latest_array_change,
"",
msg="The human readable text '' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)

View file

@ -8,6 +8,8 @@ DEBUG = True
SECRET_KEY = "test" SECRET_KEY = "test"
TEST_DB_BACKEND = os.getenv("TEST_DB_BACKEND", "sqlite3")
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
@ -20,6 +22,7 @@ INSTALLED_APPS = [
"test_app", "test_app",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
@ -28,18 +31,52 @@ MIDDLEWARE = [
"auditlog.middleware.AuditlogMiddleware", "auditlog.middleware.AuditlogMiddleware",
] ]
DATABASES = { if TEST_DB_BACKEND == "postgresql":
"default": { DATABASES = {
"ENGINE": "django.db.backends.postgresql", "default": {
"NAME": os.getenv( "ENGINE": "django.db.backends.postgresql",
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "") "NAME": os.getenv(
), "TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
"USER": os.getenv("TEST_DB_USER", "postgres"), ),
"PASSWORD": os.getenv("TEST_DB_PASS", ""), "USER": os.getenv("TEST_DB_USER", "postgres"),
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"), "PASSWORD": os.getenv("TEST_DB_PASS", ""),
"PORT": os.getenv("TEST_DB_PORT", "5432"), "HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
"PORT": os.getenv("TEST_DB_PORT", "5432"),
}
} }
} elif TEST_DB_BACKEND == "mysql":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv(
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
),
"USER": os.getenv("TEST_DB_USER", "root"),
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
"PORT": os.getenv("TEST_DB_PORT", "3306"),
"OPTIONS": {
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
},
}
}
elif TEST_DB_BACKEND == "sqlite3":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.getenv(
"TEST_DB_NAME",
(
":memory:"
if os.getenv("TOX_PARALLEL_ENV")
else "test_auditlog.sqlite3"
),
),
}
}
else:
raise ValueError(f"Unsupported database backend: {TEST_DB_BACKEND}")
TEMPLATES = [ TEMPLATES = [
{ {

View file

@ -2,8 +2,10 @@ import json
from io import StringIO from io import StringIO
from unittest.mock import patch from unittest.mock import patch
from django.conf import settings
from django.core.management import CommandError, call_command from django.core.management import CommandError, call_command
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.test.utils import skipIf
from test_app.models import SimpleModel from test_app.models import SimpleModel
from auditlog.models import LogEntry from auditlog.models import LogEntry
@ -124,6 +126,7 @@ class AuditlogMigrateJsonTest(TestCase):
# Assert # Assert
self.assertEqual(call_count, 2) self.assertEqual(call_count, 2)
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
def test_native_postgres(self): def test_native_postgres(self):
# Arrange # Arrange
log_entry = self.make_logentry() log_entry = self.make_logentry()
@ -136,6 +139,7 @@ class AuditlogMigrateJsonTest(TestCase):
self.assertEqual(errbuf, "") self.assertEqual(errbuf, "")
self.assertIsNotNone(log_entry.changes) self.assertIsNotNone(log_entry.changes)
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
def test_native_postgres_changes_not_overwritten(self): def test_native_postgres_changes_not_overwritten(self):
# Arrange # Arrange
log_entry = self.make_logentry() log_entry = self.make_logentry()

View file

@ -42,7 +42,6 @@ from test_app.models import (
ModelPrimaryKeyModel, ModelPrimaryKeyModel,
NoDeleteHistoryModel, NoDeleteHistoryModel,
NullableJSONModel, NullableJSONModel,
PostgresArrayFieldModel,
ProxyModel, ProxyModel,
RelatedModel, RelatedModel,
ReusableThroughRelatedModel, ReusableThroughRelatedModel,
@ -1717,47 +1716,6 @@ class CharFieldTextFieldModelTest(TestCase):
) )
class PostgresArrayFieldModelTest(TestCase):
databases = "__all__"
def setUp(self):
self.obj = PostgresArrayFieldModel.objects.create(
arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN],
)
@property
def latest_array_change(self):
return self.obj.history.latest().changes_display_dict["arrayfield"][1]
def test_changes_display_dict_arrayfield(self):
self.assertEqual(
self.latest_array_change,
"Red, Green",
msg="The human readable text for the two choices, 'Red, Green' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
self.obj.arrayfield = []
self.obj.save()
self.assertEqual(
self.latest_array_change,
"",
msg="The human readable text '' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
class AdminPanelTest(TestCase): class AdminPanelTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(

View file

@ -3,3 +3,4 @@ django>=4.2,<4.3
sphinx sphinx
sphinx_rtd_theme sphinx_rtd_theme
psycopg2-binary psycopg2-binary
mysqlclient==2.2.5

43
runtests.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Run tests against all supported databases
set -e
# Default settings
export TEST_DB_USER=${TEST_DB_USER:-testuser}
export TEST_DB_PASS=${TEST_DB_PASS:-testpass}
export TEST_DB_HOST=${TEST_DB_HOST:-127.0.0.1}
export TEST_DB_NAME=${TEST_DB_NAME:-auditlog}
# Cleanup on exit
trap 'docker compose -f auditlog_tests/docker-compose.yml down -v --remove-orphans 2>/dev/null || true' EXIT
echo "Starting containers..."
docker compose -f auditlog_tests/docker-compose.yml up -d
echo "Waiting for databases..."
echo "Waiting for PostgreSQL..."
until docker compose -f auditlog_tests/docker-compose.yml exec postgres pg_isready -U ${TEST_DB_USER} -d auditlog >/dev/null 2>&1; do
sleep 1
done
echo "Waiting for MySQL..."
until docker compose -f auditlog_tests/docker-compose.yml exec mysql mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS} --silent >/dev/null 2>&1; do
sleep 1
done
echo "Databases ready!"
# Run tests for each database
for backend in sqlite3 postgresql mysql; do
echo "Testing $backend..."
export TEST_DB_BACKEND=$backend
case $backend in
postgresql) export TEST_DB_PORT=5432 ;;
mysql) export TEST_DB_PORT=3306;;
sqlite3) unset TEST_DB_PORT ;;
esac
tox
done
echo "All tests completed!"

15
tox.ini
View file

@ -11,7 +11,7 @@ envlist =
[testenv] [testenv]
setenv = setenv =
COVERAGE_FILE={toxworkdir}/.coverage.{envname} COVERAGE_FILE={toxworkdir}/.coverage.{envname}.{env:TEST_DB_BACKEND}
changedir = auditlog_tests changedir = auditlog_tests
commands = commands =
coverage run --source auditlog ./manage.py test coverage run --source auditlog ./manage.py test
@ -27,7 +27,10 @@ deps =
codecov codecov
freezegun freezegun
psycopg2-binary psycopg2-binary
mysqlclient
passenv= passenv=
TEST_DB_BACKEND
TEST_DB_HOST TEST_DB_HOST
TEST_DB_USER TEST_DB_USER
TEST_DB_PASS TEST_DB_PASS
@ -56,7 +59,15 @@ description = Check for missing migrations
changedir = auditlog_tests changedir = auditlog_tests
deps = deps =
Django>=4.2 Django>=4.2
psycopg2 psycopg2-binary
mysqlclient
passenv=
TEST_DB_BACKEND
TEST_DB_HOST
TEST_DB_USER
TEST_DB_PASS
TEST_DB_NAME
TEST_DB_PORT
commands = commands =
python manage.py makemigrations --check --dry-run python manage.py makemigrations --check --dry-run