tgtest 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tgtest-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 k0te1ch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tgtest-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,353 @@
1
+ Metadata-Version: 2.4
2
+ Name: tgtest
3
+ Version: 0.1.0
4
+ Summary: End-to-end testing platform for Telegram bots (Telethon-driven, YAML + pytest).
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: telegram,telethon,testing,e2e,pytest,bot,automation
8
+ Author: k0te1ch
9
+ Author-email: khvostov40@gmail.com
10
+ Requires-Python: >=3.12,<4.0
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Testing
14
+ Classifier: Topic :: Communications :: Chat
15
+ Classifier: Framework :: Pytest
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Dist: pydantic-settings (>=2.5)
21
+ Requires-Dist: python-dotenv (>=1.0)
22
+ Requires-Dist: python-socks[asyncio] (>=2.4)
23
+ Requires-Dist: pyyaml (>=6.0)
24
+ Requires-Dist: telethon (>=1.36,<2.0)
25
+ Project-URL: Changelog, https://github.com/k0te1ch/tgtest/blob/main/CHANGELOG.md
26
+ Project-URL: Documentation, https://github.com/k0te1ch/tgtest/tree/main/docs
27
+ Project-URL: Homepage, https://github.com/k0te1ch/tgtest
28
+ Project-URL: Issues, https://github.com/k0te1ch/tgtest/issues
29
+ Project-URL: Repository, https://github.com/k0te1ch/tgtest
30
+ Description-Content-Type: text/markdown
31
+
32
+ # tgtest — End-to-end testing for Telegram bots
33
+
34
+ Drives your bots as a **real Telegram user** (via Telethon/MTProto) and asserts
35
+ on their replies. Write tests two ways:
36
+
37
+ - **YAML scenarios** — declarative, fast to write many of, no Python per test.
38
+ - **pytest / Python** — full control flow using the same client helpers.
39
+
40
+ Both run against a live bot through the same engine.
41
+
42
+ ## Why a user account?
43
+ The Telegram **Bot API can't receive messages *from* a bot**, so genuine E2E
44
+ testing requires a *user* client that sends to your bot and reads its replies.
45
+ That's what Telethon provides. You need a (test) user account.
46
+
47
+ Built on the [python-template](https://github.com/k0te1ch/python-template)
48
+ conventions: Poetry, `pydantic-settings`, a rotating-file logger, Ruff,
49
+ pre-commit, and GitHub Actions CI.
50
+
51
+ ## Documentation
52
+
53
+ Full docs live in [`docs/`](docs/README.md):
54
+ [Getting started](docs/getting-started.md) ·
55
+ [Configuration](docs/configuration.md) ·
56
+ [CLI](docs/cli.md) ·
57
+ [YAML scenarios](docs/yaml-scenarios.md) ·
58
+ [Python API](docs/python-api.md) ·
59
+ [Buttons & keyboards](docs/buttons-and-keyboards.md) ·
60
+ [Bot integration](docs/bot-integration.md) ·
61
+ [Example bot](docs/example-bot.md) ·
62
+ [Architecture](docs/architecture.md) ·
63
+ [Troubleshooting](docs/troubleshooting.md).
64
+
65
+ ## Setup
66
+
67
+ 1. Install deps (Poetry):
68
+ ```powershell
69
+ poetry install
70
+ ```
71
+ 2. Get `api_id` / `api_hash` from <https://my.telegram.org> → *API development tools*.
72
+ 3. Copy `.env.example` to `.env` and fill it in:
73
+ ```
74
+ TG_API_ID=123456
75
+ TG_API_HASH=...
76
+ TG_PHONE=+1...
77
+ TG_SESSION=tgtest.session
78
+ TG_DEFAULT_BOT=@my_bot
79
+ TG_TIMEOUT=15
80
+ TG_LOG_LEVEL=INFO
81
+ # TG_PROXY=socks5://127.0.0.1:9050 # optional; socks5/socks4/http/mtproxy
82
+ ```
83
+ Settings are loaded via `pydantic-settings` (`tgtest/config.py`); every
84
+ variable uses the `TG_` prefix. Behind a proxy? See
85
+ [docs/configuration.md → Proxy](docs/configuration.md#proxy).
86
+ 4. Log in **once** (interactive — enter the code Telegram sends, plus 2FA if set):
87
+ ```powershell
88
+ poetry run python login.py
89
+ ```
90
+ This writes an authorized `*.session` file. Test runs reuse it
91
+ non-interactively. **Never commit `.env` or `*.session`** (already gitignored).
92
+
93
+ ## Running YAML scenarios
94
+
95
+ ```powershell
96
+ poetry run tgtest run scenarios/ # a directory (recursive)
97
+ poetry run tgtest run scenarios/example_start.yaml
98
+ poetry run tgtest run "scenarios/*.yaml" --bot @other_bot
99
+ # equivalent: python -m tgtest run ... / python main.py run ...
100
+ ```
101
+
102
+ Runs are logged to `logs/tgtest.log` (rotating). Exit code is non-zero if any
103
+ scenario fails (CI-friendly). Output is per scenario `PASS` / `FAIL` with the
104
+ exact failing step and a diff-style reason.
105
+
106
+ ### Scenario format
107
+
108
+ A `.yaml` file holds one or more scenarios (separate with `---`):
109
+
110
+ ```yaml
111
+ name: Start command shows main menu
112
+ bot: "@my_bot" # optional → falls back to TG_DEFAULT_BOT
113
+ timeout: 15 # optional default per-step reply timeout (seconds)
114
+ steps:
115
+ - command: start # sends "/start" (adds the "/" for you)
116
+ - expect: # wait for next reply, assert on it
117
+ contains: "Welcome"
118
+ buttons: ["Settings", "Help"]
119
+ - click: "Settings" # press an inline button by label
120
+ - expect_edit: # bot edited the message in place
121
+ icontains: "settings"
122
+ - send: "ping" # plain text
123
+ - expect:
124
+ regex: "^pong"
125
+ ```
126
+
127
+ #### Step actions
128
+
129
+ | Step | Meaning |
130
+ |------|---------|
131
+ | `send: <text>` | Send a plain text message. |
132
+ | `command: <name>` | Send a `/command` (leading `/` optional). |
133
+ | `expect: <matcher>` | Wait for the next reply and assert on it. |
134
+ | `expect_edit: <matcher>` | Wait for the current message to be edited, then assert. |
135
+ | `expect_buttons: [..]` | Assert the current message shows these buttons (add `exact: true` for full match). |
136
+ | `expect_no_reply: <sec>` | Assert nothing arrives within N seconds. |
137
+ | `click: <label>` | Click an inline button by label (or `click:` with `index:` / `data:`). |
138
+ | `sleep: <sec>` | Pause. |
139
+
140
+ Any step may carry a `timeout:` (override) and a `note:` (shown in reports).
141
+
142
+ #### Matchers (used by `expect` / `expect_edit`)
143
+
144
+ A matcher is a string (shorthand for `equals`) or a mapping of:
145
+
146
+ - `equals`, `contains`, `icontains` (case-insensitive), `not_contains`
147
+ - `regex`, `iregex` (case-insensitive)
148
+ - `buttons: [..]` (all must be present), `buttons_exact: [..]` (whole keyboard, in order)
149
+ - `has_buttons: true|false`
150
+
151
+ Multiple clauses in one `expect` must **all** pass.
152
+
153
+ ## Running pytest / Python tests
154
+
155
+ `tests/conftest.py` already enables the plugin. Write async tests using the
156
+ `tester` fixture (a connected client) or `run_yaml` (run scenario files):
157
+
158
+ ```python
159
+ import pytest
160
+
161
+ @pytest.mark.live
162
+ async def test_start(tester):
163
+ async with tester.conversation("@my_bot") as chat:
164
+ await chat.send("/start")
165
+ await chat.expect(contains="Welcome", buttons=["Settings"])
166
+ await chat.click("Settings")
167
+ await chat.expect_edit(icontains="settings")
168
+
169
+ @pytest.mark.live
170
+ async def test_via_yaml(run_yaml):
171
+ await run_yaml("scenarios/example_start.yaml")
172
+ ```
173
+
174
+ ```powershell
175
+ poetry run pytest # run everything
176
+ poetry run pytest -m "not live" # skip tests that hit a real bot (what CI runs)
177
+ ```
178
+
179
+ Unit tests for the matchers, scenario parser, and config are **not** marked
180
+ `live`, so they run in CI without credentials.
181
+
182
+ ### `_Chat` helper API
183
+ `send`, `command`, `get_reply`, `expect(**matcher)`, `expect_edit(**matcher)`,
184
+ `expect_no_reply(within=)`, `expect_buttons(*labels, exact=)`,
185
+ `click(text=/index=/data=)`. `chat.last` is the most recent `Message`.
186
+
187
+ ## Using tgtest inside a bot project (next to unit tests)
188
+
189
+ Your bot repo keeps its **unit tests** (fast, no network) and adds **E2E tests**
190
+ that drive the real bot through tgtest. Keep the two apart with a pytest marker
191
+ so the fast suite stays the default and the slow live suite is opt-in.
192
+
193
+ The crucial difference: unit tests need nothing external; **E2E needs the bot
194
+ process actually running** (polling or webhook) so it can answer the user
195
+ client. The recipe below starts the bot for you.
196
+
197
+ > A complete, runnable version of everything in this section lives in
198
+ > [`examples/`](examples/README.md): a tiny aiogram bot with unit tests and
199
+ > tgtest E2E tests (including the bot-launch fixture). `python -m pytest
200
+ > examples/tests/unit` runs with zero setup.
201
+
202
+ ### 1. Add tgtest as a dev dependency of the bot
203
+
204
+ In the bot's `pyproject.toml` (Poetry) — git or local path:
205
+
206
+ ```toml
207
+ [tool.poetry.group.dev.dependencies]
208
+ tgtest = { git = "https://github.com/you/TelegramTests.git" }
209
+ # while iterating locally, a path dependency is handy instead:
210
+ # tgtest = { path = "../TelegramTests", develop = true }
211
+ ```
212
+
213
+ (pip equivalent: `pip install git+https://github.com/you/TelegramTests.git`
214
+ or `pip install -e ../TelegramTests`.)
215
+
216
+ ### 2. Recommended layout in the bot repo
217
+
218
+ ```
219
+ my_bot/
220
+ bot/ your bot code (entry point: python -m bot)
221
+ tests/
222
+ conftest.py enables the tgtest plugin
223
+ unit/ fast unit tests (no Telegram)
224
+ e2e/
225
+ conftest.py the "start the bot" fixture
226
+ test_start.py live tests
227
+ scenarios/ optional YAML scenarios
228
+ .env TG_* creds for the TEST user account
229
+ pyproject.toml
230
+ ```
231
+
232
+ ### 3. Enable the plugin and separate the markers
233
+
234
+ `tests/conftest.py`:
235
+
236
+ ```python
237
+ pytest_plugins = ["tgtest.pytest_plugin"] # gives you tester / run_yaml / tg_config
238
+ ```
239
+
240
+ `pyproject.toml` of the bot repo — make unit the default, E2E opt-in:
241
+
242
+ ```toml
243
+ [tool.pytest.ini_options]
244
+ asyncio_mode = "auto"
245
+ markers = ["e2e: live test that talks to the running bot"]
246
+ addopts = "-m 'not e2e'" # default `pytest` run = unit only
247
+ ```
248
+
249
+ (The marker name is yours; this platform's own examples happen to use `live`.)
250
+
251
+ ### 4. Start the bot during E2E
252
+
253
+ `tests/e2e/conftest.py` — launch the bot as a subprocess once per session and
254
+ shut it down afterward:
255
+
256
+ ```python
257
+ import os
258
+ import subprocess
259
+ import time
260
+
261
+ import pytest
262
+
263
+
264
+ @pytest.fixture(scope="session")
265
+ def bot_process():
266
+ # ALWAYS a dedicated test bot token, never production.
267
+ env = {**os.environ, "BOT_TOKEN": os.environ["TEST_BOT_TOKEN"]}
268
+ proc = subprocess.Popen(["python", "-m", "bot"], env=env)
269
+ try:
270
+ time.sleep(3) # let it connect / start polling
271
+ assert proc.poll() is None, "bot exited during startup"
272
+ yield proc
273
+ finally:
274
+ proc.terminate()
275
+ proc.wait(timeout=10)
276
+ ```
277
+
278
+ Depend on it so the bot is up before the user client talks to it:
279
+
280
+ ```python
281
+ import pytest
282
+
283
+
284
+ @pytest.mark.e2e
285
+ async def test_start(bot_process, tester):
286
+ async with tester.conversation("@my_test_bot") as chat:
287
+ await chat.send("/start")
288
+ await chat.expect(contains="Welcome")
289
+
290
+
291
+ @pytest.mark.e2e
292
+ async def test_via_yaml(bot_process, run_yaml):
293
+ await run_yaml("tests/e2e/scenarios/start.yaml")
294
+ ```
295
+
296
+ > **Readiness:** a fixed `sleep` is the simplest gate and fine for polling bots.
297
+ > For webhook bots, instead wait until the port is open or a "started" line
298
+ > appears in the bot's stdout/log — more reliable than sleeping.
299
+
300
+ ### 5. Run them
301
+
302
+ ```powershell
303
+ poetry run pytest # fast: unit only (addopts excludes e2e)
304
+ poetry run pytest -m e2e # the live end-to-end suite (overrides addopts)
305
+ poetry run pytest -o addopts="" # everything (clears the default -m filter)
306
+ ```
307
+
308
+ ### 6. CI: two jobs
309
+
310
+ - **unit** — every push / PR, no secrets: `pytest -m "not e2e"`.
311
+ - **e2e** — gated (nightly, manual, or protected branch). Provide `TG_API_ID`,
312
+ `TG_API_HASH`, `TEST_BOT_TOKEN`, and a pre-made session as CI secrets; the job
313
+ starts the bot and runs `pytest -m e2e`.
314
+
315
+ A session **can't be created interactively in CI**. Generate it once locally
316
+ (`python login.py`), then restore it in the job from a base64 secret — or run
317
+ E2E only locally / on a self-hosted runner.
318
+
319
+ ### Safety
320
+
321
+ - Use a **separate test bot** and a **separate test user account** — never a
322
+ production token or your personal account.
323
+ - Tests send real messages; talk to the bot in a dedicated test chat.
324
+
325
+ ## Layout
326
+
327
+ ```
328
+ tgtest/ the package
329
+ config.py Settings(BaseSettings) — env/.env loading (TG_ prefix)
330
+ logger.py rotating-file logger (logs/tgtest.log)
331
+ client.py BotTester + _Chat (Telethon Conversation wrapper)
332
+ matchers.py text/button matchers
333
+ scenario.py YAML → Scenario model
334
+ engine.py runs a Scenario against a chat
335
+ cli.py `tgtest run ...` entry point
336
+ pytest_plugin.py fixtures: tg_config, tester, run_yaml
337
+ main.py entry point (delegates to the CLI)
338
+ login.py one-time interactive login
339
+ scenarios/ your YAML scenarios (example included)
340
+ tests/ pytest tests (unit + live examples)
341
+ examples/ runnable reference bot (aiogram) with unit + E2E tests
342
+ logs/ rotating run logs (gitignored)
343
+ pyproject.toml Poetry project + Ruff + pytest config
344
+ .pre-commit-config.yaml, .github/workflows/ lint + CI
345
+ ```
346
+
347
+ ## Dev tooling
348
+
349
+ ```powershell
350
+ poetry run ruff check . # lint (E/F/W/C90/B/N, line-length 88)
351
+ poetry run pre-commit run --all-files
352
+ ```
353
+
tgtest-0.1.0/README.md ADDED
@@ -0,0 +1,321 @@
1
+ # tgtest — End-to-end testing for Telegram bots
2
+
3
+ Drives your bots as a **real Telegram user** (via Telethon/MTProto) and asserts
4
+ on their replies. Write tests two ways:
5
+
6
+ - **YAML scenarios** — declarative, fast to write many of, no Python per test.
7
+ - **pytest / Python** — full control flow using the same client helpers.
8
+
9
+ Both run against a live bot through the same engine.
10
+
11
+ ## Why a user account?
12
+ The Telegram **Bot API can't receive messages *from* a bot**, so genuine E2E
13
+ testing requires a *user* client that sends to your bot and reads its replies.
14
+ That's what Telethon provides. You need a (test) user account.
15
+
16
+ Built on the [python-template](https://github.com/k0te1ch/python-template)
17
+ conventions: Poetry, `pydantic-settings`, a rotating-file logger, Ruff,
18
+ pre-commit, and GitHub Actions CI.
19
+
20
+ ## Documentation
21
+
22
+ Full docs live in [`docs/`](docs/README.md):
23
+ [Getting started](docs/getting-started.md) ·
24
+ [Configuration](docs/configuration.md) ·
25
+ [CLI](docs/cli.md) ·
26
+ [YAML scenarios](docs/yaml-scenarios.md) ·
27
+ [Python API](docs/python-api.md) ·
28
+ [Buttons & keyboards](docs/buttons-and-keyboards.md) ·
29
+ [Bot integration](docs/bot-integration.md) ·
30
+ [Example bot](docs/example-bot.md) ·
31
+ [Architecture](docs/architecture.md) ·
32
+ [Troubleshooting](docs/troubleshooting.md).
33
+
34
+ ## Setup
35
+
36
+ 1. Install deps (Poetry):
37
+ ```powershell
38
+ poetry install
39
+ ```
40
+ 2. Get `api_id` / `api_hash` from <https://my.telegram.org> → *API development tools*.
41
+ 3. Copy `.env.example` to `.env` and fill it in:
42
+ ```
43
+ TG_API_ID=123456
44
+ TG_API_HASH=...
45
+ TG_PHONE=+1...
46
+ TG_SESSION=tgtest.session
47
+ TG_DEFAULT_BOT=@my_bot
48
+ TG_TIMEOUT=15
49
+ TG_LOG_LEVEL=INFO
50
+ # TG_PROXY=socks5://127.0.0.1:9050 # optional; socks5/socks4/http/mtproxy
51
+ ```
52
+ Settings are loaded via `pydantic-settings` (`tgtest/config.py`); every
53
+ variable uses the `TG_` prefix. Behind a proxy? See
54
+ [docs/configuration.md → Proxy](docs/configuration.md#proxy).
55
+ 4. Log in **once** (interactive — enter the code Telegram sends, plus 2FA if set):
56
+ ```powershell
57
+ poetry run python login.py
58
+ ```
59
+ This writes an authorized `*.session` file. Test runs reuse it
60
+ non-interactively. **Never commit `.env` or `*.session`** (already gitignored).
61
+
62
+ ## Running YAML scenarios
63
+
64
+ ```powershell
65
+ poetry run tgtest run scenarios/ # a directory (recursive)
66
+ poetry run tgtest run scenarios/example_start.yaml
67
+ poetry run tgtest run "scenarios/*.yaml" --bot @other_bot
68
+ # equivalent: python -m tgtest run ... / python main.py run ...
69
+ ```
70
+
71
+ Runs are logged to `logs/tgtest.log` (rotating). Exit code is non-zero if any
72
+ scenario fails (CI-friendly). Output is per scenario `PASS` / `FAIL` with the
73
+ exact failing step and a diff-style reason.
74
+
75
+ ### Scenario format
76
+
77
+ A `.yaml` file holds one or more scenarios (separate with `---`):
78
+
79
+ ```yaml
80
+ name: Start command shows main menu
81
+ bot: "@my_bot" # optional → falls back to TG_DEFAULT_BOT
82
+ timeout: 15 # optional default per-step reply timeout (seconds)
83
+ steps:
84
+ - command: start # sends "/start" (adds the "/" for you)
85
+ - expect: # wait for next reply, assert on it
86
+ contains: "Welcome"
87
+ buttons: ["Settings", "Help"]
88
+ - click: "Settings" # press an inline button by label
89
+ - expect_edit: # bot edited the message in place
90
+ icontains: "settings"
91
+ - send: "ping" # plain text
92
+ - expect:
93
+ regex: "^pong"
94
+ ```
95
+
96
+ #### Step actions
97
+
98
+ | Step | Meaning |
99
+ |------|---------|
100
+ | `send: <text>` | Send a plain text message. |
101
+ | `command: <name>` | Send a `/command` (leading `/` optional). |
102
+ | `expect: <matcher>` | Wait for the next reply and assert on it. |
103
+ | `expect_edit: <matcher>` | Wait for the current message to be edited, then assert. |
104
+ | `expect_buttons: [..]` | Assert the current message shows these buttons (add `exact: true` for full match). |
105
+ | `expect_no_reply: <sec>` | Assert nothing arrives within N seconds. |
106
+ | `click: <label>` | Click an inline button by label (or `click:` with `index:` / `data:`). |
107
+ | `sleep: <sec>` | Pause. |
108
+
109
+ Any step may carry a `timeout:` (override) and a `note:` (shown in reports).
110
+
111
+ #### Matchers (used by `expect` / `expect_edit`)
112
+
113
+ A matcher is a string (shorthand for `equals`) or a mapping of:
114
+
115
+ - `equals`, `contains`, `icontains` (case-insensitive), `not_contains`
116
+ - `regex`, `iregex` (case-insensitive)
117
+ - `buttons: [..]` (all must be present), `buttons_exact: [..]` (whole keyboard, in order)
118
+ - `has_buttons: true|false`
119
+
120
+ Multiple clauses in one `expect` must **all** pass.
121
+
122
+ ## Running pytest / Python tests
123
+
124
+ `tests/conftest.py` already enables the plugin. Write async tests using the
125
+ `tester` fixture (a connected client) or `run_yaml` (run scenario files):
126
+
127
+ ```python
128
+ import pytest
129
+
130
+ @pytest.mark.live
131
+ async def test_start(tester):
132
+ async with tester.conversation("@my_bot") as chat:
133
+ await chat.send("/start")
134
+ await chat.expect(contains="Welcome", buttons=["Settings"])
135
+ await chat.click("Settings")
136
+ await chat.expect_edit(icontains="settings")
137
+
138
+ @pytest.mark.live
139
+ async def test_via_yaml(run_yaml):
140
+ await run_yaml("scenarios/example_start.yaml")
141
+ ```
142
+
143
+ ```powershell
144
+ poetry run pytest # run everything
145
+ poetry run pytest -m "not live" # skip tests that hit a real bot (what CI runs)
146
+ ```
147
+
148
+ Unit tests for the matchers, scenario parser, and config are **not** marked
149
+ `live`, so they run in CI without credentials.
150
+
151
+ ### `_Chat` helper API
152
+ `send`, `command`, `get_reply`, `expect(**matcher)`, `expect_edit(**matcher)`,
153
+ `expect_no_reply(within=)`, `expect_buttons(*labels, exact=)`,
154
+ `click(text=/index=/data=)`. `chat.last` is the most recent `Message`.
155
+
156
+ ## Using tgtest inside a bot project (next to unit tests)
157
+
158
+ Your bot repo keeps its **unit tests** (fast, no network) and adds **E2E tests**
159
+ that drive the real bot through tgtest. Keep the two apart with a pytest marker
160
+ so the fast suite stays the default and the slow live suite is opt-in.
161
+
162
+ The crucial difference: unit tests need nothing external; **E2E needs the bot
163
+ process actually running** (polling or webhook) so it can answer the user
164
+ client. The recipe below starts the bot for you.
165
+
166
+ > A complete, runnable version of everything in this section lives in
167
+ > [`examples/`](examples/README.md): a tiny aiogram bot with unit tests and
168
+ > tgtest E2E tests (including the bot-launch fixture). `python -m pytest
169
+ > examples/tests/unit` runs with zero setup.
170
+
171
+ ### 1. Add tgtest as a dev dependency of the bot
172
+
173
+ In the bot's `pyproject.toml` (Poetry) — git or local path:
174
+
175
+ ```toml
176
+ [tool.poetry.group.dev.dependencies]
177
+ tgtest = { git = "https://github.com/you/TelegramTests.git" }
178
+ # while iterating locally, a path dependency is handy instead:
179
+ # tgtest = { path = "../TelegramTests", develop = true }
180
+ ```
181
+
182
+ (pip equivalent: `pip install git+https://github.com/you/TelegramTests.git`
183
+ or `pip install -e ../TelegramTests`.)
184
+
185
+ ### 2. Recommended layout in the bot repo
186
+
187
+ ```
188
+ my_bot/
189
+ bot/ your bot code (entry point: python -m bot)
190
+ tests/
191
+ conftest.py enables the tgtest plugin
192
+ unit/ fast unit tests (no Telegram)
193
+ e2e/
194
+ conftest.py the "start the bot" fixture
195
+ test_start.py live tests
196
+ scenarios/ optional YAML scenarios
197
+ .env TG_* creds for the TEST user account
198
+ pyproject.toml
199
+ ```
200
+
201
+ ### 3. Enable the plugin and separate the markers
202
+
203
+ `tests/conftest.py`:
204
+
205
+ ```python
206
+ pytest_plugins = ["tgtest.pytest_plugin"] # gives you tester / run_yaml / tg_config
207
+ ```
208
+
209
+ `pyproject.toml` of the bot repo — make unit the default, E2E opt-in:
210
+
211
+ ```toml
212
+ [tool.pytest.ini_options]
213
+ asyncio_mode = "auto"
214
+ markers = ["e2e: live test that talks to the running bot"]
215
+ addopts = "-m 'not e2e'" # default `pytest` run = unit only
216
+ ```
217
+
218
+ (The marker name is yours; this platform's own examples happen to use `live`.)
219
+
220
+ ### 4. Start the bot during E2E
221
+
222
+ `tests/e2e/conftest.py` — launch the bot as a subprocess once per session and
223
+ shut it down afterward:
224
+
225
+ ```python
226
+ import os
227
+ import subprocess
228
+ import time
229
+
230
+ import pytest
231
+
232
+
233
+ @pytest.fixture(scope="session")
234
+ def bot_process():
235
+ # ALWAYS a dedicated test bot token, never production.
236
+ env = {**os.environ, "BOT_TOKEN": os.environ["TEST_BOT_TOKEN"]}
237
+ proc = subprocess.Popen(["python", "-m", "bot"], env=env)
238
+ try:
239
+ time.sleep(3) # let it connect / start polling
240
+ assert proc.poll() is None, "bot exited during startup"
241
+ yield proc
242
+ finally:
243
+ proc.terminate()
244
+ proc.wait(timeout=10)
245
+ ```
246
+
247
+ Depend on it so the bot is up before the user client talks to it:
248
+
249
+ ```python
250
+ import pytest
251
+
252
+
253
+ @pytest.mark.e2e
254
+ async def test_start(bot_process, tester):
255
+ async with tester.conversation("@my_test_bot") as chat:
256
+ await chat.send("/start")
257
+ await chat.expect(contains="Welcome")
258
+
259
+
260
+ @pytest.mark.e2e
261
+ async def test_via_yaml(bot_process, run_yaml):
262
+ await run_yaml("tests/e2e/scenarios/start.yaml")
263
+ ```
264
+
265
+ > **Readiness:** a fixed `sleep` is the simplest gate and fine for polling bots.
266
+ > For webhook bots, instead wait until the port is open or a "started" line
267
+ > appears in the bot's stdout/log — more reliable than sleeping.
268
+
269
+ ### 5. Run them
270
+
271
+ ```powershell
272
+ poetry run pytest # fast: unit only (addopts excludes e2e)
273
+ poetry run pytest -m e2e # the live end-to-end suite (overrides addopts)
274
+ poetry run pytest -o addopts="" # everything (clears the default -m filter)
275
+ ```
276
+
277
+ ### 6. CI: two jobs
278
+
279
+ - **unit** — every push / PR, no secrets: `pytest -m "not e2e"`.
280
+ - **e2e** — gated (nightly, manual, or protected branch). Provide `TG_API_ID`,
281
+ `TG_API_HASH`, `TEST_BOT_TOKEN`, and a pre-made session as CI secrets; the job
282
+ starts the bot and runs `pytest -m e2e`.
283
+
284
+ A session **can't be created interactively in CI**. Generate it once locally
285
+ (`python login.py`), then restore it in the job from a base64 secret — or run
286
+ E2E only locally / on a self-hosted runner.
287
+
288
+ ### Safety
289
+
290
+ - Use a **separate test bot** and a **separate test user account** — never a
291
+ production token or your personal account.
292
+ - Tests send real messages; talk to the bot in a dedicated test chat.
293
+
294
+ ## Layout
295
+
296
+ ```
297
+ tgtest/ the package
298
+ config.py Settings(BaseSettings) — env/.env loading (TG_ prefix)
299
+ logger.py rotating-file logger (logs/tgtest.log)
300
+ client.py BotTester + _Chat (Telethon Conversation wrapper)
301
+ matchers.py text/button matchers
302
+ scenario.py YAML → Scenario model
303
+ engine.py runs a Scenario against a chat
304
+ cli.py `tgtest run ...` entry point
305
+ pytest_plugin.py fixtures: tg_config, tester, run_yaml
306
+ main.py entry point (delegates to the CLI)
307
+ login.py one-time interactive login
308
+ scenarios/ your YAML scenarios (example included)
309
+ tests/ pytest tests (unit + live examples)
310
+ examples/ runnable reference bot (aiogram) with unit + E2E tests
311
+ logs/ rotating run logs (gitignored)
312
+ pyproject.toml Poetry project + Ruff + pytest config
313
+ .pre-commit-config.yaml, .github/workflows/ lint + CI
314
+ ```
315
+
316
+ ## Dev tooling
317
+
318
+ ```powershell
319
+ poetry run ruff check . # lint (E/F/W/C90/B/N, line-length 88)
320
+ poetry run pre-commit run --all-files
321
+ ```