Testing Guide¶
This guide covers testing applications that use imbi-common.
Overview¶
Testing with imbi-common involves:
- Unit Tests: Test logic without external dependencies
- Integration Tests: Test with real databases (Docker)
- Mocking: Mock database clients for isolated testing
Test Framework¶
The tests are written with the standard unittest classes
(unittest.TestCase and unittest.IsolatedAsyncioTestCase). pytest runs
them, with pytest-cov for coverage. Do not use pytest fixtures or
pytest-style test functions.
Basic Test Structure¶
import unittest
from imbi.common import settings
class TestSettings(unittest.TestCase):
def test_default_postgres_url(self):
config = settings.Postgres()
self.assertIn("localhost", str(config.url))
self.assertEqual(config.graph_name, "imbi")
def test_default_pool_sizes(self):
config = settings.Postgres()
self.assertEqual(config.min_pool_size, 2)
self.assertEqual(config.max_pool_size, 10)
Async Test Cases¶
For async functions, use IsolatedAsyncioTestCase:
import unittest
from imbi.common import graph, models
class TestGraphOperations(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.db = graph.Graph()
await self.db.open()
async def asyncTearDown(self):
await self.db.close()
async def test_create_node(self):
org = models.Organization(
name="Test Org",
slug="test-org",
description="Test",
)
created = await self.db.create(org)
self.assertEqual(created.name, "Test Org")
# Cleanup
await self.db.delete(org)
Unit Tests¶
Unit tests should not require external dependencies.
Testing Settings¶
import unittest
from imbi.common import settings
class TestSettings(unittest.TestCase):
def test_postgres_defaults(self):
config = settings.Postgres()
self.assertEqual(config.graph_name, "imbi")
self.assertEqual(config.min_pool_size, 2)
self.assertEqual(config.max_pool_size, 10)
def test_clickhouse_defaults(self):
config = settings.Clickhouse()
self.assertIn("localhost", str(config.url))
Testing Models¶
import unittest
from imbi.common import models
class TestModels(unittest.TestCase):
def test_blueprint_slug_generation(self):
blueprint = models.Blueprint(
name="Cloud Provider",
type="Project",
json_schema={"type": "object", "properties": {}}
)
self.assertEqual(blueprint.slug, "cloud-provider")
Testing Auth Functions¶
import unittest
from imbi.common.auth import core
class TestAuth(unittest.TestCase):
def test_jwt_creation_and_verification(self):
token = core.create_access_token(
subject="user@example.com",
extra_claims={"role": "admin"}
)
# Verify token
payload = core.verify_token(token)
self.assertEqual(payload["sub"], "user@example.com")
self.assertEqual(payload["role"], "admin")
def test_refresh_token(self):
token = core.create_refresh_token(
subject="user@example.com"
)
payload = core.verify_token(token)
self.assertEqual(payload["sub"], "user@example.com")
self.assertEqual(payload["type"], "refresh")
Testing Logging¶
import unittest
from imbi.common import logging
class TestLogging(unittest.TestCase):
def test_get_log_config(self):
config = logging.get_log_config()
self.assertIsInstance(config, dict)
def test_configure_logging_dev_mode(self):
# Should not raise
logging.configure_logging(dev=True)
Integration Tests¶
Integration tests require Docker services. Start them with
moon run root:services before you run the tests.
import unittest
from imbi.common import graph
class TestGraphIntegration(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.db = graph.Graph()
await self.db.open()
async def asyncTearDown(self):
await self.db.close()
async def test_database_connection(self):
# execute() wraps the query in AGE cypher(), so use Cypher, not SQL
rows = await self.db.execute("RETURN 1", columns=["status"])
self.assertEqual(len(rows), 1)
Docker Compose for Tests¶
Start the test services. This command starts compose.ci.yaml and writes
the service ports to .env.test:
Run tests:
Mocking¶
Mock database clients for isolated testing.
Mocking the Graph Client¶
import unittest
from unittest.mock import AsyncMock, MagicMock
from imbi.common import graph, models
class TestServiceLogic(unittest.IsolatedAsyncioTestCase):
async def test_get_organization(self):
mock_db = MagicMock(spec=graph.Graph)
mock_org = models.Organization(
name="Mocked Org",
slug="mocked-org",
description="Test",
)
mock_db.match = AsyncMock(return_value=[mock_org])
results = await mock_db.match(
models.Organization,
{"slug": "mocked-org"},
)
self.assertEqual(results[0].name, "Mocked Org")
mock_db.match.assert_called_once()
Mocking ClickHouse¶
import unittest
from unittest.mock import AsyncMock, patch
class TestAnalytics(unittest.IsolatedAsyncioTestCase):
@patch('imbi.common.clickhouse.query')
async def test_query_analytics(self, mock_query):
mock_query.return_value = [
{"project_id": "project1", "count": 10},
{"project_id": "project2", "count": 5}
]
from imbi.common import clickhouse
results = await clickhouse.query(
"SELECT project_id, COUNT(*) AS count "
"FROM commits FINAL GROUP BY project_id"
)
self.assertEqual(len(results), 2)
self.assertEqual(results[0]["project_id"], "project1")
Running Tests¶
Run All Tests¶
moon run common:test starts the services first (root:services).
Run the other commands below from the repository root, after
moon run root:services has written .env.test.
Run Specific Test File¶
Run Specific Test Method¶
uv run --frozen --env-file .env.test pytest \
libraries/common/tests/test_settings.py::PostgresSettingsTestCase::test_default_settings
Run with Verbose Output¶
Continuous Integration¶
CI runs moon ci in .github/workflows/test.yml. Use that workflow as
the reference for how CI starts the services and runs the tests.
Test Coverage¶
Track test coverage with coverage (via moon run root:coverage):
# Run the full suite with aggregate coverage (requires Docker)
moon run root:coverage
# Run a specific file
uv run --env-file .env.test pytest libraries/common/tests/test_settings.py
The coverage configuration is in libraries/common/pyproject.toml,
under [tool.coverage.run] and [tool.coverage.report]. It measures
imbi.common (source_pkgs), with branch coverage, and fails under
85%. moon run common:test passes this file to pytest-cov with
--cov-config. There is no .coveragerc file.
Best Practices¶
- Isolate Tests: Each test should be independent
- Clean Up: Always clean up test data in
tearDown/asyncTearDown - Share Setup: Create reusable test data in
setUp/asyncSetUpor in helper methods - Mock External Services: Don't hit real APIs in unit tests
- Test Edge Cases: Test error conditions and boundary cases
- Fast Unit Tests: Keep unit tests fast by avoiding I/O
- Clear Test Names: Use descriptive test method names
- One Assert Per Test: Focus each test on one behavior