Тестирование
Правильно тестируемая конфигурация требует, чтобы:
- Unit-тесты не читали файлы с диска.
- Тестируемая логика получала конфиг программно.
- Проверка hot-reload — через контролируемые триггеры.
Unit-тесты — inline-конфиг через in-memory источник
Вместо чтения app-config.yaml, собирайте конфиг прямо в тесте.
Имя in-memory источника различается между биндингами:
| Биндинг | Класс |
|---|---|
dagstack-config (Python) | InMemorySource |
@dagstack/config (TypeScript) | InMemorySource |
go.dagstack.dev/config (Go) | DictSource (конструктор NewDictSource) |
- Python
- TypeScript
- Go
import pytest
from dagstack.config import Config, InMemorySource
from app.database import DatabaseConfig, DatabasePool
def test_pool_uses_configured_size():
config = Config.load_from([
InMemorySource({
"database": {
"host": "localhost",
"port": 5432,
"name": "test",
"user": "app",
"password": "test-pw",
"pool_size": 42,
},
}),
])
pool = DatabasePool(config.get_section("database", DatabaseConfig))
assert pool.size == 42
def test_pool_rejects_invalid_host():
config = Config.load_from([
InMemorySource({
"database": {
"host": "0.0.0.0", # запрещено валидатором
"name": "test",
"user": "app",
"password": "pw",
},
}),
])
with pytest.raises(ValueError, match="host must be a concrete"):
config.get_section("database", DatabaseConfig)
import { describe, it, expect } from "vitest";
import { Config, InMemorySource } from "@dagstack/config";
import { DatabaseConfigSchema, DatabasePool } from "../src/database";
describe("DatabasePool", () => {
it("uses configured size", async () => {
const config = await Config.loadFrom([
new InMemorySource({
database: {
host: "localhost",
port: 5432,
name: "test",
user: "app",
password: "test-pw",
pool_size: 42,
},
}),
]);
const pool = new DatabasePool(config.getSection("database", DatabaseConfigSchema));
expect(pool.size).toBe(42);
});
it("rejects invalid host", async () => {
const config = await Config.loadFrom([
new InMemorySource({
database: {
host: "0.0.0.0",
name: "test",
user: "app",
password: "pw",
},
}),
]);
expect(() => config.getSection("database", DatabaseConfigSchema)).toThrow(
/host must be a concrete/,
);
});
});
func TestPoolUsesConfiguredSize(t *testing.T) {
cfg, _ := config.LoadFrom(context.Background(), []config.Source{
config.NewDictSource(config.Tree{
"database": map[string]any{
"host": "localhost",
"port": 5432,
"name": "test",
"user": "app",
"password": "test-pw",
"pool_size": 42,
},
}),
})
var dbCfg DatabaseConfig
_ = cfg.GetSection("database", &dbCfg)
pool := NewDatabasePool(dbCfg)
if pool.Size != 42 {
t.Fatalf("expected size 42, got %v", pool.Size)
}
}
Файловый тест во временном каталоге — YamlFileSource в tmpdir
Когда нужно проверить именно YAML-парсинг (вложенные значения по умолчанию, env-интерполяция), пишите YAML во временный каталог:
- Python
- TypeScript
- Go
import pytest
from pathlib import Path
from dagstack.config import Config
@pytest.fixture
def app_config_yaml(tmp_path: Path, monkeypatch) -> Path:
# Env-переменные для интерполяции:
monkeypatch.setenv("DB_PASSWORD", "test-pw")
yaml_path = tmp_path / "app-config.yaml"
yaml_path.write_text("""
database:
host: "${DB_HOST:-localhost}"
password: "${DB_PASSWORD}"
name: "test_db"
user: "app"
pool_size: 5
""")
return yaml_path
def test_env_interpolation(app_config_yaml):
config = Config.load(str(app_config_yaml))
assert config.get_string("database.password") == "test-pw"
assert config.get_string("database.host") == "localhost"
import { test, expect } from "vitest";
import * as fs from "fs/promises";
import * as path from "path";
import * as os from "os";
import { Config } from "@dagstack/config";
test("env interpolation", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cfg-"));
await fs.writeFile(
path.join(tmp, "app-config.yaml"),
`
database:
host: "\${DB_HOST:-localhost}"
password: "\${DB_PASSWORD}"
name: "test_db"
user: "app"
`,
);
process.env.DB_PASSWORD = "test-pw";
const config = await Config.load(path.join(tmp, "app-config.yaml"));
expect(config.getString("database.password")).toBe("test-pw");
});
func TestEnvInterpolation(t *testing.T) {
dir := t.TempDir()
yamlPath := filepath.Join(dir, "app-config.yaml")
os.WriteFile(yamlPath, []byte(`
database:
host: "${DB_HOST:-localhost}"
password: "${DB_PASSWORD}"
name: "test_db"
user: "app"
`), 0644)
t.Setenv("DB_PASSWORD", "test-pw")
cfg, _ := config.Load(context.Background(), yamlPath)
got, _ := cfg.GetString("database.password")
if got != "test-pw" { t.Fatal(got) }
}
Hot-reload — Phase 2
:::caution Не реализовано в v0.1 / v0.2
Hot-reload и подписки на изменения — запланированный Phase 2 API.
В текущих биндингах (dagstack-config v0.2.0, config-go v0.1.0,
@dagstack/config v0.1.0) методы-регистраторы (on_change /
on_section_change / reload в Python, OnChange / OnSectionChange /
Reload в Go) присутствуют в API, но callback никогда не вызывается:
reload() — no-op, on_change возвращает inactive Subscription.
Mutable источник (MutableDictSource) и атомарный откат при невалидной
перезагрузке — часть того же Phase 2 scope.
Для тестов в v0.1/v0.2 используйте immutable конфиг: собирайте отдельный
Config под каждый сценарий через load_from / loadFrom / LoadFrom.
:::
Интеграционные тесты с DAGSTACK_ENV
Для e2e-сценариев, где важен порядок слоёв, подставляйте DAGSTACK_ENV:
- Python
- TypeScript
- Go
def test_production_overrides_base(tmp_path, monkeypatch):
(tmp_path / "app-config.yaml").write_text("""
database:
pool_size: 20
host: "localhost"
name: "test"
user: "app"
password: "pw"
""")
(tmp_path / "app-config.production.yaml").write_text("""
database:
pool_size: 100
""")
monkeypatch.setenv("DAGSTACK_ENV", "production")
monkeypatch.chdir(tmp_path)
config = Config.load("app-config.yaml")
assert config.get_int("database.pool_size") == 100
test("production layer overrides base", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cfg-"));
await fs.writeFile(path.join(tmp, "app-config.yaml"),
"database:\n pool_size: 20\n host: 'localhost'\n name: 'test'\n user: 'app'\n password: 'pw'\n");
await fs.writeFile(path.join(tmp, "app-config.production.yaml"),
"database:\n pool_size: 100\n");
process.env.DAGSTACK_ENV = "production";
const config = await Config.load(path.join(tmp, "app-config.yaml"));
expect(config.getInt("database.pool_size")).toBe(100);
});
func TestProductionOverridesBase(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "app-config.yaml"), []byte(`
database:
pool_size: 20
host: "localhost"
name: "test"
user: "app"
password: "pw"
`), 0644)
os.WriteFile(filepath.Join(dir, "app-config.production.yaml"), []byte(`
database:
pool_size: 100
`), 0644)
t.Setenv("DAGSTACK_ENV", "production")
cfg, _ := config.Load(context.Background(), filepath.Join(dir, "app-config.yaml"))
got, _ := cfg.GetInt("database.pool_size")
if got != 100 {
t.Fatalf("expected 100, got %d", got)
}
}
Прогон в CI
name: Config tests
on: [push, pull_request]
jobs:
test:
runs-on: dagstack-runner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install -e '.[test]'
- run: pytest tests/ -v
См. также
- Объявить секцию — создание schema.
- Hot-reload (watch) — подписки в production.
- Источники (ConfigSource) —
YamlFileSource,JsonFileSource,InMemorySource(Python/TS) /DictSource(Go).