Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions .agents/context/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ pytest -m "not server" # skip tests that need a live server (CI
```

Config lives in `pyproject.toml` (`[tool.pytest.ini_options]`): discovery is scoped to
`py/tests/` and `pythonpath = ["py"]` makes `import visdom` work without an editable install.
Experimental `test_*.py` scripts in the repo root (and `test/`) are intentionally out of scope.
`py/tests/`, and `pythonpath = ["py", "py/tests"]` makes both `import visdom` and
`import testutils` work without an editable install. Experimental `test_*.py` scripts in the
repo root (and `test/`) are intentionally out of scope.

## Run E2E / Visual Tests (Cypress)

Expand All @@ -38,12 +39,57 @@ Always use port `8098` and `-env_path /tmp` for isolation.

## Writing Python Tests

- Place in `py/tests/`, name files `test_*.py`, classes `Test*` (unittest `TestCase` or plain
pytest functions both work — pytest auto-discovers unittest).
- Keep them hermetic (no running server). A test that genuinely needs a live server must be
marked `@pytest.mark.server` so CI can deselect it.
- Start with simple hermetic tests (e.g., `test_smoke.py`) and add focused unit tests for
server/window/env lifecycle code as coverage grows.
### Where a test goes

```
py/tests/
conftest.py shared fixtures, auto-loaded by pytest
testutils/ importable helpers (fakes, payload builders, HTTP base class)
unit/ pure logic: no Application, no I/O beyond tmp_path
integration/ in-process Application, real HTTP, or handler dispatch
```

`py/tests/` has **no** `__init__.py` on purpose — `setup.py` runs `find_packages(where="py")`,
so a package there would ship a top-level `tests` distribution to users. `testutils/` is a
package and is reachable because `py/tests` is on `pythonpath`.

- Name files `test_*.py`. **Write plain `def test_*()` functions, not `unittest.TestCase`
subclasses.** pytest still runs `TestCase` (much of the suite predates this rule), but
**pytest cannot inject fixtures into `TestCase` methods** — `def test_x(self, app)` fails.
Only autouse fixtures reach them. A `TestCase` therefore cannot use anything in the table
below, and cannot use `@pytest.mark.parametrize` either.
- Keep them hermetic. A test that needs an **externally launched** server must be marked
`@pytest.mark.server` so CI can deselect it; nothing in the tracked suite needs one today.

### Shared fixtures (`py/tests/conftest.py`)

| Fixture | Gives you |
|---|---|
| `env_path` | disposable environment directory |
| `store` / `spy_store` | `JSONStore` / one that records backend calls |
| `app` / `app_factory` | `Application` on a temp `env_path`; factory for reload assertions |
| `handler` / `app_handler` | duck-typed handler, standalone or sharing an `Application`'s state |
| `fake_socket` | records `write_message`; `.commands()` and `.last(cmd)` for assertions |
| `offline_client` | `Visdom(send=False)` — never opens a connection |
| `capture_send` | runs a client call and returns the payload it would have sent |

`reset_warn_once` is autouse: `shared_utils.warn_once` dedupes against a module-level set, so
without it a warning raised by one test silently suppresses the same warning in another.

HTTP tests are the one exception to the plain-function rule today: subclass
`testutils.VisdomHTTPTestCase`, which starts the app in-process on an ephemeral port, gives every
test a fresh `env_path` that is cleaned up, and provides `post_json`, `create_window`,
`create_text_window`, `get_envs`, `get_win_data`, `win_exists` and `panes`. Override the
`app_kwargs` class attribute to vary server configuration.

It is a `TestCase` only because `tornado.testing.AsyncHTTPTestCase` is one, so the fixtures above
are unavailable inside it. It is scheduled to be replaced by an equivalent `visdom_server` fixture
that runs the app on a background event loop and talks to it with `requests`, which removes the
last reason for any `TestCase` in this suite.

### Markers

`unit`, `integration`, `slow`, and `server` are registered in `pyproject.toml`.

## CI

Expand Down
141 changes: 141 additions & 0 deletions py/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Fixtures shared by the whole suite.

Everything here is hermetic: temporary directories only, no listening sockets,
no network. Tests that genuinely need an externally launched visdom must carry
``@pytest.mark.server`` so CI can deselect them.
"""

from unittest.mock import patch

import pytest

from visdom.data_model.json_store import JSONStore
from visdom.server.app import Application
from visdom.utils import shared_utils

from testutils.fakes import FakeHandler, FakeSocket, SpyStore


@pytest.fixture
def env_path(tmp_path):
"""Disposable environment directory."""
return str(tmp_path)


@pytest.fixture
def store(env_path):
"""JSONStore backed by a temporary directory."""
return JSONStore(env_path)


@pytest.fixture
def spy_store(env_path):
"""JSONStore that records which backend methods the server calls."""
return SpyStore(env_path)


@pytest.fixture
def app(env_path):
"""Application instance with temporary persistence and no listener.

The port is only recorded on the instance; nothing binds it, so this is
safe to construct in parallel with other tests.
"""
return Application(port=8097, env_path=env_path)


@pytest.fixture
def app_factory(env_path):
"""Build Applications sharing one ``env_path``, for reload assertions."""

def build(**kwargs):
kwargs.setdefault("port", 8097)
kwargs.setdefault("env_path", env_path)
return Application(**kwargs)

return build


@pytest.fixture
def fake_socket():
return FakeSocket()


@pytest.fixture
def handler(env_path):
"""Duck-typed handler with its own empty state and store."""
return FakeHandler(env_path=env_path)


@pytest.fixture
def app_handler(app):
"""Handler sharing a real Application's state, storage and subscribers."""
return FakeHandler(
state=app.state,
storage=app.storage,
subs=app.subs,
sources=app.sources,
env_path=app.env_path,
)


@pytest.fixture
def offline_client():
"""Visdom client that never opens a connection.

With ``send=False`` the client's ``_send`` returns the payload instead of
performing I/O, so plot methods can be asserted as pure functions.
"""
import visdom

return visdom.Visdom(send=False, use_incoming_socket=False)


@pytest.fixture
def capture_send(offline_client):
"""Run a client call and return the payload it would have transmitted.

Usage::

sent = capture_send(lambda v: v.line(Y=[1, 2, 3]))
assert sent["payload"]["data"][0]["type"] == "scatter"
"""

def run(call, win_exists=None):
sent = {}

def capture(msg, endpoint="events", **_):
sent["payload"] = msg
sent["endpoint"] = endpoint
return "win_capture"

patches = [patch.object(offline_client, "_send", side_effect=capture)]
if win_exists is not None:
patches.append(
patch.object(offline_client, "win_exists", return_value=win_exists)
)
for p in patches:
p.start()
try:
call(offline_client)
finally:
for p in reversed(patches):
p.stop()
return sent

return run


@pytest.fixture(autouse=True)
def reset_warn_once():
"""Isolate ``shared_utils.warn_once`` between tests.

It dedupes against a module-level set, so without this a warning raised by
an earlier test silently suppresses the same warning in a later one, and
assertions on it pass or fail depending on execution order.
"""
previous = set(shared_utils._seen_warnings)
shared_utils._seen_warnings.clear()
yield
shared_utils._seen_warnings.clear()
shared_utils._seen_warnings.update(previous)
Loading