Claude
Skills
Sign in
Back

testing-patterns

Included with Lifetime
$97 forever

How to write robust Frappe tests — FrappeTestCase, factories, mocking frappe.sendmail / external HTTP, permission tests, change_settings, anti-patterns to avoid. Use when writing or reviewing test_*.py files, designing test fixtures, or thinking about test isolation. The /frappe-test command runs tests; this skill teaches how to write them.

Writing & Docs

What this skill does


# Frappe Testing Patterns

Reference for writing maintainable tests in Frappe v14+. Focused on the patterns that hold up as your test suite grows past a few dozen tests — factory functions, fixture isolation, mocking, and the anti-patterns that cause flaky failures in CI.

## Which base class

```python
from frappe.tests.utils import FrappeTestCase
```

Use `FrappeTestCase` (not `unittest.TestCase`) for anything that touches Frappe — controllers, APIs, DB. It handles:
- Per-test transaction rollback (you don't need to clean up created docs in `tearDown`)
- `frappe.flags.in_test = True` (so production code can branch on test mode if needed)
- Test record loading from `test_records.json`
- Permission isolation (resets `frappe.session.user` between tests)

Use plain `unittest.TestCase` only for pure-Python helpers with no Frappe touchpoints (string utilities, math, etc.).

## File location and naming

```
my_app/
└── my_module/
    └── doctype/
        └── expense_claim/
            ├── expense_claim.py
            ├── expense_claim.js
            ├── expense_claim.json
            ├── test_expense_claim.py    ← convention
            └── test_records.json        ← optional: seed data for the suite
```

Frappe discovers tests by walking each app's `<module>/doctype/<doctype>/test_*.py`. For non-DocType tests (utilities, integrations), put them in `my_app/tests/` and Frappe still finds them.

Test methods must start with `test_`. Helper methods on the test class (factories, common assertions) should not — name them `_make_invoice`, `_assert_balanced`, etc.

## Test data lifecycle

### `test_records.json` — suite-level fixtures

```json
[
    {
        "doctype": "Customer",
        "customer_name": "_Test Customer Alpha",
        "customer_type": "Company",
        "territory": "_Test Territory"
    },
    {
        "doctype": "Item",
        "item_code": "_Test Item Widget",
        "item_name": "Widget",
        "stock_uom": "Nos"
    }
]
```

Loaded once per test run, available to every test in the file via `frappe.get_doc("Customer", "_Test Customer Alpha")`. Use the `_Test` prefix on names — it's the team convention so test data is visually distinct from production seed data.

`test_records.json` is loaded with `ignore_permissions=True`, so it can create docs the test user doesn't normally have access to.

### `setUp` vs `setUpClass`

```python
class TestExpenseClaim(FrappeTestCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        # Runs ONCE per test class. Use for read-only setup that won't be mutated.
        cls.approver_user = "[email protected]"
        cls.create_approver_if_missing()

    def setUp(self):
        # Runs before EACH test. Use for state that tests will mutate.
        # Don't put expensive setup here.
        frappe.set_user("Administrator")

    def tearDown(self):
        # Often unneeded — FrappeTestCase rolls back the transaction automatically.
        # Use only for things that escape the transaction (file writes, real HTTP).
        pass
```

Most tests need no `setUp` or `tearDown` at all — the per-test rollback handles it.

### Factory pattern

For docs that vary per test, write a `_make_<thing>` helper that takes overrides:

```python
class TestExpenseClaim(FrappeTestCase):
    def _make_expense_claim(self, **overrides):
        defaults = {
            "doctype": "Expense Claim",
            "employee": "_Test Employee Alpha",
            "expenses": [{
                "expense_type": "Travel",
                "amount": 100.0,
                "expense_date": frappe.utils.nowdate(),
            }],
        }
        defaults.update(overrides)
        doc = frappe.get_doc(defaults)
        doc.insert()
        return doc

    def test_total_calculation(self):
        claim = self._make_expense_claim(expenses=[
            {"expense_type": "Travel", "amount": 100.0, "expense_date": frappe.utils.nowdate()},
            {"expense_type": "Food", "amount": 50.0, "expense_date": frappe.utils.nowdate()},
        ])
        self.assertEqual(claim.total_claimed_amount, 150.0)

    def test_default_status(self):
        claim = self._make_expense_claim()
        self.assertEqual(claim.status, "Draft")
```

Factories should be **idempotent on the parts that don't change between tests**. The Customer and Item are seed data; only the Expense Claim is created per test.

## Permission tests

```python
def test_employee_cannot_approve_own_claim(self):
    # Set the user we're testing as
    frappe.set_user("[email protected]")  # Employee role
    claim = self._make_expense_claim(employee="HR-EMP-Alice")

    # Try the privileged operation
    with self.assertRaises(frappe.PermissionError):
        from frappe.model.workflow import apply_workflow
        apply_workflow(claim, "Approve")

    # Reset before the next test (FrappeTestCase auto-resets, but explicit is safer)
    frappe.set_user("Administrator")

def test_manager_can_approve(self):
    self._make_employee("[email protected]", roles=["Employee"])
    self._make_employee("[email protected]", roles=["Employee", "Expense Approver"])

    frappe.set_user("[email protected]")
    claim = self._make_expense_claim()
    apply_workflow(claim, "Submit")

    frappe.set_user("[email protected]")
    apply_workflow(claim, "Approve")
    self.assertEqual(claim.workflow_state, "Approved")
```

Always reset to `Administrator` (or `Guest` for unauthenticated tests) explicitly — even though `FrappeTestCase` does it for you, it makes the intent obvious and protects against test ordering changes.

## Mocking external calls

Use `unittest.mock.patch` from the standard library. Frappe ships no special mocking framework.

### Mock `frappe.sendmail`

Most tests should not actually send mail. Patch it for the duration of the test:

```python
from unittest.mock import patch

class TestExpenseClaim(FrappeTestCase):
    @patch("frappe.sendmail")
    def test_approval_sends_notification(self, mock_sendmail):
        claim = self._make_expense_claim()
        apply_workflow(claim, "Approve")

        mock_sendmail.assert_called_once()
        call_kwargs = mock_sendmail.call_args.kwargs
        self.assertIn("approved", call_kwargs["subject"].lower())
        self.assertEqual(call_kwargs["recipients"], [claim.employee_email])
```

### Mock external HTTP

```python
@patch("requests.post")
def test_webhook_fires_on_submit(self, mock_post):
    mock_post.return_value.status_code = 200
    mock_post.return_value.json.return_value = {"received": True}

    claim = self._make_expense_claim()
    claim.submit()

    mock_post.assert_called_once()
    self.assertEqual(mock_post.call_args.args[0], "https://hooks.example.com/expense")
```

### Mock `frappe.publish_realtime`

```python
@patch("frappe.publish_realtime")
def test_progress_published_during_long_job(self, mock_publish):
    run_my_long_job(args)
    self.assertTrue(mock_publish.called)
    # Inspect call_args_list for ordering
```

## `frappe.flags.in_test`

Set automatically by `FrappeTestCase`. Use it sparingly in production code:

```python
def send_notification(recipient, message):
    if frappe.flags.in_test:
        # Skip real send during tests — but tests should mock frappe.sendmail directly anyway
        return
    frappe.sendmail(recipients=[recipient], message=message)
```

Prefer mocking the function in the test over branching on `in_test` in production code. Branching pollutes business logic with test concerns; mocking keeps the production path clean.

## `change_settings` context manager

For tests that depend on a Single doctype's setting:

```python
from frappe.tests.utils import change_settings

class TestStockNegative(FrappeTestCase):
    def test_negative_stock_blocked(self):
        # Default: should not allow negative stock
        with self.assertRaises(frappe.ValidationError):
            self._make_delivery_note_exceeding_stock()

    def test_negative_stock_allowed_when_setting_on(self

Related in Writing & Docs