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 +21 -0
- tgtest-0.1.0/PKG-INFO +353 -0
- tgtest-0.1.0/README.md +321 -0
- tgtest-0.1.0/pyproject.toml +92 -0
- tgtest-0.1.0/tgtest/__init__.py +26 -0
- tgtest-0.1.0/tgtest/__main__.py +6 -0
- tgtest-0.1.0/tgtest/cli.py +104 -0
- tgtest-0.1.0/tgtest/client.py +227 -0
- tgtest-0.1.0/tgtest/config.py +50 -0
- tgtest-0.1.0/tgtest/engine.py +75 -0
- tgtest-0.1.0/tgtest/exceptions.py +23 -0
- tgtest-0.1.0/tgtest/logger.py +41 -0
- tgtest-0.1.0/tgtest/matchers.py +125 -0
- tgtest-0.1.0/tgtest/proxy.py +92 -0
- tgtest-0.1.0/tgtest/pytest_plugin.py +53 -0
- tgtest-0.1.0/tgtest/scenario.py +135 -0
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
|
+
```
|