mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
feat: introduce smoke test group and fail-fast test orchestration (#572)
This commit is contained in:
32
.github/workflows/build.yml
vendored
32
.github/workflows/build.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
###########################################################
|
###########################################################
|
||||||
build:
|
build:
|
||||||
###########################################################
|
###########################################################
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
@@ -144,8 +144,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Check with pip-audit
|
- name: Check with pip-audit
|
||||||
# until https://github.com/astral-sh/ruff/issues/8277
|
# until https://github.com/astral-sh/ruff/issues/8277
|
||||||
run:
|
run:
|
||||||
pdm run pip-audit --progress-spinner off --skip-editable --verbose
|
pdm run pip-audit --progress-spinner off --skip-editable --verbose
|
||||||
|
|
||||||
|
|
||||||
- name: Check with ruff
|
- name: Check with ruff
|
||||||
@@ -169,7 +169,7 @@ jobs:
|
|||||||
set -eux
|
set -eux
|
||||||
|
|
||||||
case "${{ matrix.os }}" in
|
case "${{ matrix.os }}" in
|
||||||
ubuntu-*)
|
ubuntu-*)
|
||||||
sudo apt-get install --no-install-recommends -y xvfb
|
sudo apt-get install --no-install-recommends -y xvfb
|
||||||
xvfb-run pdm run itest:cov -vv
|
xvfb-run pdm run itest:cov -vv
|
||||||
;;
|
;;
|
||||||
@@ -178,6 +178,10 @@ jobs:
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
- name: Run smoke tests
|
||||||
|
run: pdm run smoke:cov -vv
|
||||||
|
|
||||||
|
|
||||||
- name: Run app from source
|
- name: Run app from source
|
||||||
run: |
|
run: |
|
||||||
echo "
|
echo "
|
||||||
@@ -299,7 +303,7 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
- name: List coverage reports
|
- name: List coverage reports
|
||||||
run: find . -name coverage-*.xml
|
run: find . -name coverage-*.xml
|
||||||
|
|
||||||
|
|
||||||
- name: Publish unit-test coverage
|
- name: Publish unit-test coverage
|
||||||
@@ -326,6 +330,18 @@ jobs:
|
|||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
|
- name: Publish smoke-test coverage
|
||||||
|
uses: codecov/codecov-action@v5 # https://github.com/codecov/codecov-action
|
||||||
|
with:
|
||||||
|
slug: ${{ github.repository }}
|
||||||
|
name: smoke-coverage
|
||||||
|
flags: smoke-tests
|
||||||
|
disable_search: true
|
||||||
|
files: coverage/**/coverage-smoke.xml
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
|
||||||
###########################################################
|
###########################################################
|
||||||
publish-release:
|
publish-release:
|
||||||
###########################################################
|
###########################################################
|
||||||
@@ -341,9 +357,9 @@ jobs:
|
|||||||
# – publish-coverage succeeded OR was skipped
|
# – publish-coverage succeeded OR was skipped
|
||||||
if: >
|
if: >
|
||||||
always()
|
always()
|
||||||
&& needs.build.result == 'success'
|
&& needs.build.result == 'success'
|
||||||
&& (needs.publish-coverage.result == 'success' || needs.publish-coverage.result == 'skipped')
|
&& (needs.publish-coverage.result == 'success' || needs.publish-coverage.result == 'skipped')
|
||||||
&& (github.ref_name == 'main' || github.ref_name == 'release') && !github.event.act
|
&& (github.ref_name == 'main' || github.ref_name == 'release') && !github.event.act
|
||||||
|
|
||||||
concurrency: publish-${{ github.ref_name }}-release # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idconcurrency
|
concurrency: publish-${{ github.ref_name }}-release # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idconcurrency
|
||||||
|
|
||||||
@@ -362,7 +378,7 @@ jobs:
|
|||||||
uses: vegardit/fast-apt-mirror.sh@v1
|
uses: vegardit/fast-apt-mirror.sh@v1
|
||||||
|
|
||||||
|
|
||||||
- name: Git Checkout
|
- name: Git Checkout
|
||||||
# only required by "gh release create" to prevent "fatal: Not a git repository"
|
# only required by "gh release create" to prevent "fatal: Not a git repository"
|
||||||
uses: actions/checkout@v4 # https://github.com/actions/checkout
|
uses: actions/checkout@v4 # https://github.com/actions/checkout
|
||||||
|
|
||||||
|
|||||||
288
CONTRIBUTING.md
288
CONTRIBUTING.md
@@ -1,36 +1,300 @@
|
|||||||
|
# Table of Contents
|
||||||
|
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Development Notes](#development-notes)
|
||||||
|
- [Development Workflow](#development-workflow)
|
||||||
|
- [Testing Requirements](#testing-requirements)
|
||||||
|
- [Code Quality Standards](#code-quality-standards)
|
||||||
|
- [Bug Reports](#bug-reports)
|
||||||
|
- [Feature Requests](#feature-requests)
|
||||||
|
- [Pull Request Requirements](#pull-request-requirements)
|
||||||
|
- [Performance Considerations](#performance-considerations)
|
||||||
|
- [Security and Best Practices](#security-and-best-practices)
|
||||||
|
- [Licensing](#licensing)
|
||||||
|
- [Internationalization (i18n) and Translations](#internationalization-i18n-and-translations)
|
||||||
|
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
Thanks for your interest in contributing to this project! Whether it's a bug report, new feature, correction, or additional documentation, we greatly value feedback and contributions from our community.
|
Thanks for your interest in contributing to this project! Whether it's a bug report, new feature, correction, or additional documentation, we greatly value feedback and contributions from our community.
|
||||||
|
|
||||||
We want to make contributing as easy and transparent as possible.
|
We want to make contributing as easy and transparent as possible. Contributions via [pull requests](#pull-request-requirements) are much appreciated.
|
||||||
|
|
||||||
Please read through this document before submitting any contributions to ensure your contribution goes to the correct code repository and we have all the necessary information to effectively respond to your request.
|
Please read through this document before submitting any contributions to ensure your contribution goes to the correct code repository and we have all the necessary information to effectively respond to your request.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Python 3.10 or higher
|
||||||
|
- PDM for dependency management
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### Local Setup
|
||||||
|
1. Fork and clone the repository
|
||||||
|
2. Install dependencies: `pdm install`
|
||||||
|
3. Run tests to verify setup: `pdm run test:cov`
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
This section provides quick reference commands for common development tasks. See ‘Testing Requirements’ below for more details on running and organizing tests.
|
||||||
|
|
||||||
|
- Format source code: `pdm run format`
|
||||||
|
- Run tests: `pdm run test` (see 'Testing Requirements' below for more details)
|
||||||
|
- Run syntax checks: `pdm run lint`
|
||||||
|
- Linting issues found by ruff can be auto-fixed using `pdm run lint:fix`
|
||||||
|
- Derive JSON schema files from Pydantic data model: `pdm run generate-schemas`
|
||||||
|
- Create platform-specific executable: `pdm run compile`
|
||||||
|
- Application bootstrap works like this:
|
||||||
|
```python
|
||||||
|
pdm run app
|
||||||
|
|-> executes 'python -m kleinanzeigen_bot'
|
||||||
|
|-> executes 'kleinanzeigen_bot/__main__.py'
|
||||||
|
|-> executes main() function of 'kleinanzeigen_bot/__init__.py'
|
||||||
|
|-> executes KleinanzeigenBot().run()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Before Submitting
|
||||||
|
1. **Format your code**: Ensure your code is auto-formatted
|
||||||
|
```bash
|
||||||
|
pdm run format
|
||||||
|
```
|
||||||
|
2. **Lint your code**: Check for linting errors and warnings
|
||||||
|
```bash
|
||||||
|
pdm run lint
|
||||||
|
```
|
||||||
|
3. **Run tests**: Ensure all tests pass locally
|
||||||
|
```bash
|
||||||
|
pdm run test
|
||||||
|
```
|
||||||
|
4. **Check code quality**: Verify your code follows project standards
|
||||||
|
- Type hints are complete
|
||||||
|
- Docstrings are present
|
||||||
|
- SPDX headers are included
|
||||||
|
- Imports are properly organized
|
||||||
|
5. **Test your changes**: Add appropriate tests for new functionality
|
||||||
|
- Add smoke tests for critical paths
|
||||||
|
- Add unit tests for new components
|
||||||
|
- Add integration tests for external dependencies
|
||||||
|
|
||||||
|
### Commit Messages
|
||||||
|
Use clear, descriptive commit messages that explain:
|
||||||
|
- What was changed
|
||||||
|
- Why it was changed
|
||||||
|
- Any breaking changes or important notes
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
feat: add smoke test for bot startup
|
||||||
|
|
||||||
|
- Add test_bot_starts_without_crashing to verify core workflow
|
||||||
|
- Use DummyBrowser to avoid real browser dependencies
|
||||||
|
- Follows existing smoke test patterns in tests/smoke/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
This project uses a comprehensive testing strategy with three test types:
|
||||||
|
|
||||||
|
### Test Types
|
||||||
|
- **Unit tests** (`tests/unit/`): Isolated component tests with mocks. Run first.
|
||||||
|
- **Integration tests** (`tests/integration/`): Tests with real external dependencies. Run after unit tests.
|
||||||
|
- **Smoke tests** (`tests/smoke/`): Minimal, post-deployment health checks that verify the most essential workflows (e.g., app starts, config loads, login page reachable). Run after integration tests. Smoke tests are not end-to-end (E2E) tests and should not cover full user workflows.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
# Run all tests in order (unit → integration → smoke)
|
||||||
|
pdm run test:cov
|
||||||
|
|
||||||
|
# Run specific test types
|
||||||
|
pdm run utest # Unit tests only
|
||||||
|
pdm run itest # Integration tests only
|
||||||
|
pdm run smoke # Smoke tests only
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pdm run utest:cov # Unit tests with coverage
|
||||||
|
pdm run itest:cov # Integration tests with coverage
|
||||||
|
pdm run smoke:cov # Smoke tests with coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding New Tests
|
||||||
|
1. **Determine test type** based on what you're testing:
|
||||||
|
- **Smoke tests**: Minimal, critical health checks (not full user workflows)
|
||||||
|
- **Unit tests**: Individual components, isolated functionality
|
||||||
|
- **Integration tests**: External dependencies, real network calls
|
||||||
|
|
||||||
|
2. **Place in correct directory**:
|
||||||
|
- `tests/smoke/` for smoke tests
|
||||||
|
- `tests/unit/` for unit tests
|
||||||
|
- `tests/integration/` for integration tests
|
||||||
|
|
||||||
|
3. **Add proper markers**:
|
||||||
|
```python
|
||||||
|
@pytest.mark.smoke # For smoke tests
|
||||||
|
@pytest.mark.itest # For integration tests
|
||||||
|
@pytest.mark.asyncio # For async tests
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Use existing fixtures** when possible (see `tests/conftest.py`)
|
||||||
|
|
||||||
|
For detailed testing guidelines, see [docs/TESTING.md](docs/TESTING.md).
|
||||||
|
|
||||||
|
## Code Quality Standards
|
||||||
|
|
||||||
|
### File Headers
|
||||||
|
All Python files must start with SPDX license headers:
|
||||||
|
```python
|
||||||
|
# SPDX-FileCopyrightText: © <your name> and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Organization
|
||||||
|
- Use absolute imports for project modules: `from kleinanzeigen_bot import KleinanzeigenBot`
|
||||||
|
- Use relative imports for test utilities: `from tests.conftest import SmokeKleinanzeigenBot`
|
||||||
|
- Group imports: standard library, third-party, local (with blank lines between groups)
|
||||||
|
|
||||||
|
### Type Hints
|
||||||
|
- Always use type hints for function parameters and return values
|
||||||
|
- Use `Any` from `typing` for complex types
|
||||||
|
- Use `Final` for constants
|
||||||
|
- Use `cast()` when type checker needs help
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
#### Docstrings
|
||||||
|
- Use docstrings for **complex functions and classes that need explanation**
|
||||||
|
- Include examples in docstrings for complex functions (see `utils/misc.py` for examples)
|
||||||
|
|
||||||
|
#### Comments
|
||||||
|
- **Use comments to explain your code logic and reasoning**
|
||||||
|
- Comment on complex algorithms, business logic, and non-obvious decisions
|
||||||
|
- Explain "why" not just "what" - the reasoning behind implementation choices
|
||||||
|
- Use comments for edge cases, workarounds, and platform-specific code
|
||||||
|
|
||||||
|
#### Module Documentation
|
||||||
|
- Add module docstrings for packages and complex modules
|
||||||
|
- Document the purpose and contents of each module
|
||||||
|
|
||||||
|
#### Model Documentation
|
||||||
|
- Use `Field(description="...")` for Pydantic model fields to document their purpose
|
||||||
|
- Include examples in field descriptions for complex configurations
|
||||||
|
- Document validation rules and constraints
|
||||||
|
|
||||||
|
#### Logging
|
||||||
|
- Use structured logging with `loggers.get_logger()`
|
||||||
|
- Include context in log messages to help with debugging
|
||||||
|
- Use appropriate log levels (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
- Log important state changes and decision points
|
||||||
|
|
||||||
|
#### Examples
|
||||||
|
```python
|
||||||
|
def parse_duration(text: str) -> timedelta:
|
||||||
|
"""
|
||||||
|
Parses a human-readable duration string into a datetime.timedelta.
|
||||||
|
|
||||||
|
Supported units:
|
||||||
|
- d: days
|
||||||
|
- h: hours
|
||||||
|
- m: minutes
|
||||||
|
- s: seconds
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> parse_duration("1h 30m")
|
||||||
|
datetime.timedelta(seconds=5400)
|
||||||
|
"""
|
||||||
|
# Use regex to find all duration parts
|
||||||
|
pattern = re.compile(r"(\d+)\s*([dhms])")
|
||||||
|
parts = pattern.findall(text.lower())
|
||||||
|
|
||||||
|
# Build timedelta from parsed parts
|
||||||
|
kwargs: dict[str, int] = {}
|
||||||
|
for value, unit in parts:
|
||||||
|
if unit == "d":
|
||||||
|
kwargs["days"] = kwargs.get("days", 0) + int(value)
|
||||||
|
elif unit == "h":
|
||||||
|
kwargs["hours"] = kwargs.get("hours", 0) + int(value)
|
||||||
|
# ... handle other units
|
||||||
|
return timedelta(**kwargs)
|
||||||
|
```
|
||||||
|
### Error Handling
|
||||||
|
- Use specific exception types when possible
|
||||||
|
- Include meaningful error messages
|
||||||
|
- Use `pytest.fail()` with descriptive messages in tests
|
||||||
|
- Use `pyright: ignore[reportAttributeAccessIssue]` for known type checker issues
|
||||||
|
|
||||||
## Reporting Bugs/Feature Requests
|
## Reporting Bugs/Feature Requests
|
||||||
|
|
||||||
We use GitHub issues to track bugs and feature requests. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue.
|
We use GitHub issues to track bugs and feature requests. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue.
|
||||||
|
|
||||||
|
### Bug Reports
|
||||||
|
When reporting a bug, please ensure you:
|
||||||
|
- Confirm the issue is reproducible on the latest release
|
||||||
|
- Clearly describe the expected and actual behavior
|
||||||
|
- Provide detailed steps to reproduce the issue
|
||||||
|
- Include relevant log output if available
|
||||||
|
- Specify your operating system and browser (if applicable)
|
||||||
|
- Agree to the project's Code of Conduct
|
||||||
|
|
||||||
## Contributing via Pull Requests
|
This helps maintainers quickly triage and address issues.
|
||||||
|
|
||||||
Contributions via pull requests are much appreciated.
|
### Feature Requests
|
||||||
|
Include:
|
||||||
|
- Clear description of the desired feature
|
||||||
|
- Use case or problem it solves
|
||||||
|
- Any implementation ideas or considerations
|
||||||
|
|
||||||
Before sending us a pull request, please ensure that:
|
## Pull Request Requirements
|
||||||
|
|
||||||
1. You are working against the latest source on the **main** branch.
|
Before submitting a pull request, please ensure you:
|
||||||
1. You check existing open and recently merged pull requests to make sure someone else hasn't already addressed the issue.
|
|
||||||
|
|
||||||
To send us a pull request, please:
|
1. **Work from the latest source on the main branch**
|
||||||
|
2. **Create a feature branch** for your changes: `git checkout -b feature/your-feature-name`
|
||||||
|
3. **Format your code**: `pdm run format`
|
||||||
|
4. **Lint your code**: `pdm run lint`
|
||||||
|
5. **Run all tests**: `pdm run test`
|
||||||
|
6. **Check code quality**: Type hints, docstrings, SPDX headers, import organization
|
||||||
|
7. **Add appropriate tests** for new functionality (smoke/unit/integration as needed)
|
||||||
|
8. **Write clear, descriptive commit messages**
|
||||||
|
9. **Provide a concise summary and motivation for the change in the PR**
|
||||||
|
10. **List all key changes and dependencies**
|
||||||
|
11. **Select the correct type(s) of change** (bug fix, feature, breaking change)
|
||||||
|
12. **Complete the checklist in the PR template**
|
||||||
|
13. **Confirm your contribution can be used under the project license**
|
||||||
|
|
||||||
1. Fork our repository.
|
See the [Pull Request template](.github/PULL_REQUEST_TEMPLATE.md) for the full checklist and required fields.
|
||||||
1. Modify the source while focusing on the specific change you are contributing.
|
|
||||||
1. Commit to your fork using clear, descriptive commit messages.
|
To submit a pull request:
|
||||||
1. Send us a pull request, answering any default questions in the pull request interface.
|
- Fork our repository
|
||||||
|
- Push your feature branch to your fork
|
||||||
|
- Open a pull request on GitHub, answering any default questions in the interface
|
||||||
|
|
||||||
GitHub provides additional documentation on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/)
|
GitHub provides additional documentation on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/)
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- **Smoke tests** should be fast (< 1 second each)
|
||||||
|
- **Unit tests** should be isolated and fast
|
||||||
|
- **Integration tests** can be slower but should be minimal
|
||||||
|
- Use fakes/dummies to avoid real network calls in tests
|
||||||
|
|
||||||
|
## Security and Best Practices
|
||||||
|
|
||||||
|
- Never commit real credentials in tests
|
||||||
|
- Use temporary files and directories for test data
|
||||||
|
- Clean up resources in test teardown
|
||||||
|
- Use environment variables for configuration
|
||||||
|
- Follow the principle of least privilege in test setup
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
See the [LICENSE.txt](LICENSE.txt) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
|
See the [LICENSE.txt](LICENSE.txt) file for our project's licensing. All source files must include SPDX license headers as described above. We will ask you to confirm the licensing of your contribution.
|
||||||
|
|
||||||
|
## Internationalization (i18n) and Translations
|
||||||
|
|
||||||
|
- All user-facing output (log messages, print statements, CLI help, etc.) must be written in **English**.
|
||||||
|
- For every user-facing message, a **German translation** must be added to `src/kleinanzeigen_bot/resources/translations.de.yaml`.
|
||||||
|
- Use the translation system for all output—**never hardcode German or other languages** in the code.
|
||||||
|
- If you add or change a user-facing message, update the translation file and ensure that translation completeness tests pass (`tests/unit/test_translations.py`).
|
||||||
|
- Review the translation guidelines and patterns in the codebase for correct usage.
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -19,6 +19,7 @@
|
|||||||
1. [Related Open-Source Projects](#related)
|
1. [Related Open-Source Projects](#related)
|
||||||
1. [License](#license)
|
1. [License](#license)
|
||||||
|
|
||||||
|
For details on the new smoke test strategy and contributor guidance, see [TESTING.md](./docs/TESTING.md).
|
||||||
|
|
||||||
## <a name="about"></a>About
|
## <a name="about"></a>About
|
||||||
|
|
||||||
@@ -438,25 +439,6 @@ By default a new browser process will be launched. To reuse a manually launched
|
|||||||
|
|
||||||
> Please read [CONTRIBUTING.md](CONTRIBUTING.md) before contributing code. Thank you!
|
> Please read [CONTRIBUTING.md](CONTRIBUTING.md) before contributing code. Thank you!
|
||||||
|
|
||||||
- Format source code: `pdm run format`
|
|
||||||
- Run tests:
|
|
||||||
- unit tests: `pdm run utest` - with coverage: `pdm run utest:cov`
|
|
||||||
- integration tests: `pdm run itest` - with coverage: `pdm run itest:cov`
|
|
||||||
- all tests: `pdm run test` - with coverage: `pdm run test:cov`
|
|
||||||
- Run syntax checks: `pdm run lint`
|
|
||||||
- Linting issues found by ruff can be auto-fixed using `pdm run lint:fix`
|
|
||||||
- Derive JSON schema files from Pydantic data model: `pdm run generate-schemas`
|
|
||||||
- Create platform-specific executable: `pdm run compile`
|
|
||||||
- Application bootstrap works like this:
|
|
||||||
```python
|
|
||||||
pdm run app
|
|
||||||
|-> executes 'python -m kleinanzeigen_bot'
|
|
||||||
|-> executes 'kleinanzeigen_bot/__main__.py'
|
|
||||||
|-> executes main() function of 'kleinanzeigen_bot/__init__.py'
|
|
||||||
|-> executes KleinanzeigenBot().run()
|
|
||||||
````
|
|
||||||
|
|
||||||
|
|
||||||
## <a name="related"></a>Related Open-Source projects
|
## <a name="related"></a>Related Open-Source projects
|
||||||
|
|
||||||
- [DanielWTE/ebay-kleinanzeigen-api](https://github.com/DanielWTE/ebay-kleinanzeigen-api) (Python) API interface to get random listings from kleinanzeigen.de
|
- [DanielWTE/ebay-kleinanzeigen-api](https://github.com/DanielWTE/ebay-kleinanzeigen-api) (Python) API interface to get random listings from kleinanzeigen.de
|
||||||
|
|||||||
102
docs/TESTING.md
Normal file
102
docs/TESTING.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# TESTING.md
|
||||||
|
|
||||||
|
## Test Strategy and Types
|
||||||
|
|
||||||
|
This project uses a layered testing approach, with a focus on reliability and fast feedback. The test types are:
|
||||||
|
|
||||||
|
- **Unit tests**: Isolated, fast tests targeting the smallest testable units (functions, classes) in isolation. Run first.
|
||||||
|
- **Integration tests**: Tests that verify the interaction between components or with real external dependencies. Run after unit tests.
|
||||||
|
- **Smoke tests**: Minimal set of critical checks, run after a successful build and (optionally) after deployment. Their goal is to verify that the most essential workflows (e.g., app starts, config loads, login page reachable) work and that the system is stable enough for deeper testing. Smoke tests are not end-to-end (E2E) tests and should not cover full user workflows.
|
||||||
|
|
||||||
|
### Principles
|
||||||
|
|
||||||
|
- **Test observable behavior, not internal implementation**
|
||||||
|
- **Avoid mocks** in smoke tests; use custom fake components (e.g., dummy browser/page objects)
|
||||||
|
- **Write tests that verify outcomes**, not method call sequences
|
||||||
|
- **Keep tests simple and maintainable**
|
||||||
|
|
||||||
|
### Fakes vs. Mocks
|
||||||
|
|
||||||
|
- **Fakes**: Lightweight, custom classes that simulate real dependencies (e.g., DummyBrowser, DummyPage)
|
||||||
|
- **Mocks**: Avoided in smoke tests; no patching, MagicMock, or side_effect trees
|
||||||
|
|
||||||
|
### Example Smoke Tests
|
||||||
|
|
||||||
|
- Minimal checks that the application starts and does not crash
|
||||||
|
- Verifying that a config file can be loaded without error
|
||||||
|
- Checking that a login page is reachable (but not performing a full login workflow)
|
||||||
|
|
||||||
|
### Why This Approach?
|
||||||
|
|
||||||
|
- Lower maintenance burden
|
||||||
|
- Contributors can understand and extend tests
|
||||||
|
- Quick CI feedback on whether the bot still runs at all
|
||||||
|
|
||||||
|
## Smoke Test Marking and Execution
|
||||||
|
|
||||||
|
### Marking Smoke Tests
|
||||||
|
|
||||||
|
- All smoke tests **must** be marked with `@pytest.mark.smoke`.
|
||||||
|
- Place smoke tests in `tests/smoke/` for discoverability.
|
||||||
|
- Example:
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bot_starts(smoke_bot):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Smoke, Unit, and Integration Tests
|
||||||
|
|
||||||
|
- **Unit tests:**
|
||||||
|
- Run with: `pdm run utest` (excludes smoke and integration tests)
|
||||||
|
- Coverage: `pdm run utest:cov`
|
||||||
|
- **Integration tests:**
|
||||||
|
- Run with: `pdm run itest` (excludes smoke tests)
|
||||||
|
- Coverage: `pdm run itest:cov`
|
||||||
|
- **Smoke tests:**
|
||||||
|
- Run with: `pdm run smoke`
|
||||||
|
- Coverage: `pdm run smoke:cov`
|
||||||
|
- **All tests in order:**
|
||||||
|
- Run with: `pdm run test` (runs unit, then integration, then smoke)
|
||||||
|
|
||||||
|
### CI Test Order
|
||||||
|
|
||||||
|
- CI runs unit tests first, then integration tests, then smoke tests.
|
||||||
|
- Coverage for each group is uploaded separately to Codecov (with flags: `unit-tests`, `integration-tests`, `smoke-tests`).
|
||||||
|
- This ensures that foundational failures are caught early and that test types are clearly separated.
|
||||||
|
|
||||||
|
### Adding New Smoke Tests
|
||||||
|
|
||||||
|
- Add new tests to `tests/smoke/` and mark them with `@pytest.mark.smoke`.
|
||||||
|
- Use fakes/dummies for browser and page dependencies (see `tests/conftest.py`).
|
||||||
|
- Focus on minimal, critical health checks, not full user workflows.
|
||||||
|
|
||||||
|
### Why This Structure?
|
||||||
|
|
||||||
|
- **Fast feedback:** Unit and integration tests catch most issues before running smoke tests.
|
||||||
|
- **Separation:** Unit, integration, and smoke tests are not polluted by each other.
|
||||||
|
- **Coverage clarity:** You can see which code paths are covered by each test type in Codecov.
|
||||||
|
|
||||||
|
See also: `pyproject.toml` for test script definitions and `.github/workflows/build.yml` for CI setup.
|
||||||
|
|
||||||
|
## Why Use Composite Test Groups?
|
||||||
|
|
||||||
|
### Failing Fast and Early Feedback
|
||||||
|
|
||||||
|
- **Failing fast:** By running unit tests first, then integration, then smoke tests, CI and contributors get immediate feedback if a foundational component is broken.
|
||||||
|
- **Critical errors surface early:** If a unit test fails, the job stops before running slower or less critical tests, saving time and resources.
|
||||||
|
- **CI efficiency:** This approach prevents running hundreds of integration/smoke tests if the application is fundamentally broken (e.g., cannot start, cannot load config, etc.).
|
||||||
|
- **Clear separation:** Each test group (unit, integration, smoke) is reported and covered separately, making it easy to see which layer is failing.
|
||||||
|
|
||||||
|
### Tradeoff: Unified Reporting vs. Fast Failure
|
||||||
|
|
||||||
|
- **Unified reporting:** Running all tests in a single pytest invocation gives a single summary of all failures, but does not fail fast on critical errors.
|
||||||
|
- **Composite groups:** Running groups separately means you may only see the first group's failures, but you catch the most important issues as soon as possible.
|
||||||
|
|
||||||
|
### When to Use Which
|
||||||
|
|
||||||
|
- **CI:** Composite groups are preferred for CI to catch critical failures early and avoid wasting resources.
|
||||||
|
- **Local development:** You may prefer a unified run (`pdm run test`) to see all failures at once. Both options can be provided in `pyproject.toml` for flexibility.
|
||||||
@@ -106,12 +106,35 @@ lint = { composite = ["lint:ruff", "lint:mypy", "lint:pyright"] }
|
|||||||
"lint:fix" = {shell = "ruff check --preview --fix" }
|
"lint:fix" = {shell = "ruff check --preview --fix" }
|
||||||
|
|
||||||
# tests
|
# tests
|
||||||
test = "python -m pytest --capture=tee-sys"
|
# Run unit tests only (exclude smoke and itest)
|
||||||
utest = "python -m pytest --capture=tee-sys -m 'not itest'"
|
utest = "python -m pytest --capture=tee-sys -m \"not itest and not smoke\""
|
||||||
itest = "python -m pytest --capture=tee-sys -m 'itest'"
|
# Run integration tests only (exclude smoke)
|
||||||
"test:cov" = { composite = ["utest:cov", "itest:cov"] }
|
itest = "python -m pytest --capture=tee-sys -m \"itest and not smoke\""
|
||||||
"utest:cov" = { composite = ["utest --cov=src/kleinanzeigen_bot --cov-report=xml:.temp/coverage-unit.xml"] }
|
# Run smoke tests only
|
||||||
"itest:cov" = { composite = ["itest --cov=src/kleinanzeigen_bot --cov-report=xml:.temp/coverage-integration.xml"] }
|
smoke = "python -m pytest --capture=tee-sys -m smoke"
|
||||||
|
# Run all tests in order: unit, integration, smoke
|
||||||
|
# (for CI: run these three scripts in sequence)
|
||||||
|
test = { composite = ["utest", "itest", "smoke"] }
|
||||||
|
# Run all tests in a single invocation for unified summary (unit tests run first)
|
||||||
|
"test:unified" = "python -m pytest --capture=tee-sys"
|
||||||
|
#
|
||||||
|
# Coverage scripts:
|
||||||
|
# - Each group writes its own data file to .temp/.coverage.<group>.xml
|
||||||
|
#
|
||||||
|
"test:cov" = { composite = ["utest:cov", "itest:cov", "smoke:cov", "coverage:combine"] }
|
||||||
|
"utest:cov" = { shell = "python -m pytest --capture=tee-sys -m \"not itest and not smoke\" --cov=src/kleinanzeigen_bot --cov-report=xml:.temp/coverage-unit.xml --cov-append" }
|
||||||
|
"itest:cov" = { shell = "python -m pytest --capture=tee-sys -m \"itest and not smoke\" --cov=src/kleinanzeigen_bot --cov-report=xml:.temp/coverage-integration.xml --cov-append" }
|
||||||
|
"smoke:cov" = { shell = "python -m pytest --capture=tee-sys -m smoke --cov=src/kleinanzeigen_bot --cov-report=xml:.temp/coverage-smoke.xml --cov-append" }
|
||||||
|
"coverage:combine" = { shell = "coverage report -m" }
|
||||||
|
# Run all tests with coverage in a single invocation
|
||||||
|
"test:cov:unified" = "python -m pytest --capture=tee-sys --cov=src/kleinanzeigen_bot --cov-report=term-missing"
|
||||||
|
|
||||||
|
# Test script structure:
|
||||||
|
# - Composite test groups (unit, integration, smoke) are run in order to fail fast and surface critical errors early.
|
||||||
|
# - This prevents running all tests if a foundational component is broken, saving time.
|
||||||
|
# - Each group is covered and reported separately.
|
||||||
|
#
|
||||||
|
# See docs/TESTING.md for more details.
|
||||||
|
|
||||||
|
|
||||||
#####################
|
#####################
|
||||||
@@ -322,6 +345,7 @@ addopts = """
|
|||||||
--cov-report=term-missing
|
--cov-report=term-missing
|
||||||
"""
|
"""
|
||||||
markers = [
|
markers = [
|
||||||
|
"smoke: marks a test as a high-level smoke test (critical path, no mocks)",
|
||||||
"itest: marks a test as an integration test (i.e. a test with external dependencies)",
|
"itest: marks a test as an integration test (i.e. a test with external dependencies)",
|
||||||
"asyncio: mark test as async"
|
"asyncio: mark test as async"
|
||||||
]
|
]
|
||||||
|
|||||||
8
tests/__init__.py
Normal file
8
tests/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
|
|
||||||
|
# This file makes the tests/ directory a Python package.
|
||||||
|
# It is required so that direct imports like 'from tests.conftest import ...' work correctly,
|
||||||
|
# and to avoid mypy errors about duplicate module names when using such imports.
|
||||||
|
# Pytest does not require this for fixture discovery, but Python and mypy do for package-style imports.
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
import os
|
import os
|
||||||
from typing import Any, Final
|
from typing import Any, Final, cast
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from kleinanzeigen_bot import KleinanzeigenBot
|
from kleinanzeigen_bot import KleinanzeigenBot
|
||||||
from kleinanzeigen_bot.extract import AdExtractor
|
from kleinanzeigen_bot.extract import AdExtractor
|
||||||
|
from kleinanzeigen_bot.model.ad_model import Ad
|
||||||
from kleinanzeigen_bot.model.config_model import Config
|
from kleinanzeigen_bot.model.config_model import Config
|
||||||
from kleinanzeigen_bot.utils import loggers
|
from kleinanzeigen_bot.utils import loggers
|
||||||
from kleinanzeigen_bot.utils.web_scraping_mixin import Browser
|
from kleinanzeigen_bot.utils.web_scraping_mixin import Browser
|
||||||
@@ -181,3 +182,64 @@ def mock_web_text_responses() -> list[str]:
|
|||||||
@pytest.fixture(autouse = True)
|
@pytest.fixture(autouse = True)
|
||||||
def silence_nodriver_logs() -> None:
|
def silence_nodriver_logs() -> None:
|
||||||
loggers.get_logger("nodriver").setLevel(loggers.WARNING)
|
loggers.get_logger("nodriver").setLevel(loggers.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Smoke test fakes and fixtures ---
|
||||||
|
|
||||||
|
class DummyBrowser:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.page = DummyPage()
|
||||||
|
self._process_pid = None # Use None to indicate no real process
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
pass # Dummy method to satisfy close_browser_session
|
||||||
|
|
||||||
|
|
||||||
|
class DummyPage:
|
||||||
|
def find_element(self, selector:str) -> "DummyElement":
|
||||||
|
return DummyElement()
|
||||||
|
|
||||||
|
|
||||||
|
class DummyElement:
|
||||||
|
def click(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def type(self, text:str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SmokeKleinanzeigenBot(KleinanzeigenBot):
|
||||||
|
"""A test subclass that overrides async methods for smoke testing."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
# Use cast to satisfy type checker for browser attribute
|
||||||
|
self.browser = cast(Browser, DummyBrowser())
|
||||||
|
|
||||||
|
def close_browser_session(self) -> None:
|
||||||
|
# Override to avoid psutil.Process logic in tests
|
||||||
|
self.page = None # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
if self.browser:
|
||||||
|
self.browser.stop()
|
||||||
|
self.browser = None # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
async def login(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def publish_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_ads(self, *, ignore_inactive:bool = True, exclude_ads_with_id:bool = True) -> list[tuple[str, Ad, dict[str, Any]]]:
|
||||||
|
# Use cast to satisfy type checker for dummy Ad value
|
||||||
|
return [("dummy_file", cast(Ad, None), {})]
|
||||||
|
|
||||||
|
def load_config(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def smoke_bot() -> SmokeKleinanzeigenBot:
|
||||||
|
"""Fixture providing a ready-to-use smoke test bot instance."""
|
||||||
|
bot = SmokeKleinanzeigenBot()
|
||||||
|
bot.command = "publish"
|
||||||
|
return bot
|
||||||
|
|||||||
103
tests/smoke/test_smoke_health.py
Normal file
103
tests/smoke/test_smoke_health.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
|
"""
|
||||||
|
Minimal smoke tests: post-deployment health checks for kleinanzeigen-bot.
|
||||||
|
These tests verify that the most essential components are operational.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess # noqa: S404
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kleinanzeigen_bot.model.config_model import Config
|
||||||
|
from kleinanzeigen_bot.utils import i18n
|
||||||
|
from tests.conftest import DummyBrowser, DummyPage, SmokeKleinanzeigenBot
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_app_starts(smoke_bot:SmokeKleinanzeigenBot) -> None:
|
||||||
|
"""Smoke: Bot can be instantiated and started without error."""
|
||||||
|
assert smoke_bot is not None
|
||||||
|
# Optionally call a minimal method if available
|
||||||
|
assert hasattr(smoke_bot, "run") or hasattr(smoke_bot, "login")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_config_loads() -> None:
|
||||||
|
"""Smoke: Minimal config loads successfully."""
|
||||||
|
minimal_cfg = {
|
||||||
|
"ad_defaults": {"contact": {"name": "dummy", "zipcode": "12345"}},
|
||||||
|
"login": {"username": "dummy", "password": "dummy"},
|
||||||
|
"publishing": {"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False},
|
||||||
|
}
|
||||||
|
config = Config.model_validate(minimal_cfg)
|
||||||
|
assert config.login.username == "dummy"
|
||||||
|
assert config.login.password == "dummy" # noqa: S105
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_logger_initializes(tmp_path:Path, caplog:pytest.LogCaptureFixture) -> None:
|
||||||
|
"""Smoke: Logger can be initialized and used, robust to pytest log capture."""
|
||||||
|
log_path = tmp_path / "smoke_test.log"
|
||||||
|
logger_name = "smoke_test_logger_unique"
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.propagate = False
|
||||||
|
# Remove all handlers to start clean
|
||||||
|
for h in list(logger.handlers):
|
||||||
|
logger.removeHandler(h)
|
||||||
|
# Create and attach a file handler
|
||||||
|
handle = logging.FileHandler(str(log_path), encoding = "utf-8")
|
||||||
|
handle.setLevel(logging.DEBUG)
|
||||||
|
formatter = logging.Formatter("%(levelname)s:%(name)s:%(message)s")
|
||||||
|
handle.setFormatter(formatter)
|
||||||
|
logger.addHandler(handle)
|
||||||
|
# Log a message
|
||||||
|
logger.info("Smoke test log message")
|
||||||
|
# Flush and close the handler
|
||||||
|
handle.flush()
|
||||||
|
handle.close()
|
||||||
|
# Remove the handler from the logger
|
||||||
|
logger.removeHandler(handle)
|
||||||
|
assert log_path.exists()
|
||||||
|
with open(log_path, "r", encoding = "utf-8") as f:
|
||||||
|
contents = f.read()
|
||||||
|
assert "Smoke test log message" in contents
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_translation_system_healthy() -> None:
|
||||||
|
"""Smoke: Translation system loads and retrieves a known key."""
|
||||||
|
# Use a known string that should exist in translations (fallback to identity)
|
||||||
|
en = i18n.translate("Login", None)
|
||||||
|
assert isinstance(en, str)
|
||||||
|
assert len(en) > 0
|
||||||
|
# Switch to German and test
|
||||||
|
i18n.set_current_locale(i18n.Locale("de"))
|
||||||
|
de = i18n.translate("Login", None)
|
||||||
|
assert isinstance(de, str)
|
||||||
|
assert len(de) > 0
|
||||||
|
# Reset locale
|
||||||
|
i18n.set_current_locale(i18n.Locale("en"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_dummy_browser_session() -> None:
|
||||||
|
"""Smoke: Dummy browser session can be created and closed."""
|
||||||
|
browser = DummyBrowser()
|
||||||
|
page = browser.page
|
||||||
|
assert isinstance(page, DummyPage)
|
||||||
|
browser.stop() # Should not raise
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_cli_entrypoint_help_runs() -> None:
|
||||||
|
"""Smoke: CLI entry point runs with --help and exits cleanly (subprocess)."""
|
||||||
|
cli_module = "kleinanzeigen_bot.__main__"
|
||||||
|
result = subprocess.run([sys.executable, "-m", cli_module, "--help"], check = False, capture_output = True, text = True) # noqa: S603
|
||||||
|
assert result.returncode in {0, 1}, f"CLI exited with unexpected code: {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||||
|
assert "Usage" in result.stdout or "usage" in result.stdout or "help" in result.stdout.lower(), f"No help text in CLI output: {result.stdout}"
|
||||||
Reference in New Issue
Block a user