diff --git a/CLAUDE.md b/CLAUDE.md index 8e2ec91..9d0bac7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,23 @@ Types encouraged: `feat:`, `fix:`, `docs:`, `refactor:` ## Testing -### Unit Tests +### Unit Test Principles + +Write tests that earn their keep. Every test should justify its existence by verifying something non-obvious. + +**What to test:** +- **Non-trivial transformations** — regex, recursion, type dispatch, multi-step logic where inputs interact in non-obvious ways +- **Boundary conditions** — empty inputs, None values, edge cases where behavior changes (e.g., leading digits in identifiers) +- **Regression tests for bugs** — every bug fix should include a test that would have caught it. The test should fail without the fix. +- **Integration-style unit tests** — testing a function end-to-end with real inputs is more valuable than mocking every internal call (e.g., test `generate_graphql_schema()` with a real schema, not with mocked sub-functions) + +**What not to test:** +- **Trivial wrappers** — if a function is a one-liner delegating to another tested function (e.g., `".".join([f(x) for x in path.split(".")])`) +- **Framework behavior** — don't test that Pydantic validates types or that `re.sub` works; test *your* logic +- **Obvious guard clauses** — `if not s: return s` doesn't need its own test case unless the empty-input behavior is part of a documented contract +- **Coverage for coverage's sake** — a placeholder test like `assert module is not None` has no value; either write a real test or leave the file empty + +### Unit Test Mechanics - Tests are in `test/` mirroring source structure - Uses pytest with `asyncio_mode = auto` - Run specific module tests: `uv run pytest test/components/lif//` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c028ba4..7726934 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,21 +97,39 @@ pre-commit run --all-files --- -## Running Tests +## Testing -Tests are required for new features and bugfixes. +Tests are required for new features and bug fixes. -Run the test suite with: +### Running Tests ```bash -uxv pytest +uv run pytest # Run all tests +uv run pytest test/components/lif// # Run tests for a specific component +uv run pytest test/path/to/test_file.py -v # Run a specific test file ``` -Or if the virtual environment has been activated with `source .venv/bin.activate`: +### What Makes a Good Unit Test -```bash -pytest -``` +Write tests that earn their keep. Every test should verify something non-obvious about the code's behavior. + +**Do test:** +- **Non-trivial transformations** — regex logic, recursion, type dispatch, multi-step pipelines where inputs interact in unexpected ways +- **Boundary conditions** — empty inputs, None values, edge cases where behavior changes (e.g., leading digits in identifiers, CamelCase splitting combined with special characters) +- **Bug regressions** — every bug fix should include a test that fails without the fix and passes with it +- **End-to-end behavior** — testing a function with real inputs is more valuable than mocking every internal call. For example, test `generate_graphql_schema()` with an actual OpenAPI schema rather than mocking sub-functions. + +**Don't test:** +- **Trivial wrappers** — if a function is a one-liner delegating to another tested function, testing it adds noise, not confidence +- **Framework behavior** — don't test that Pydantic validates types or that `re.sub` works; test *your* logic that uses them +- **Obvious guard clauses** — `if not s: return s` doesn't need its own test case unless the empty-input behavior is a documented contract +- **Coverage for coverage's sake** — a placeholder test like `assert module is not None` has no value. Either write a real test or leave the file empty. + +**Guidelines:** +- Mirror the source structure in `test/` (`test/components/lif//test_core.py`) +- Group related tests into classes for organization +- Prefer real objects over mocks when practical — mocks can mask bugs in the interaction between components +- Use `pytest.raises()` with `match=` to verify specific error messages, not just error types --- diff --git a/components/lif/string_utils/core.py b/components/lif/string_utils/core.py index 6884657..b134a09 100644 --- a/components/lif/string_utils/core.py +++ b/components/lif/string_utils/core.py @@ -19,6 +19,7 @@ def safe_identifier(name: str) -> str: s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name) s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1) safe = re.sub(r"\W|^(?=\d)", "_", s2) + safe = re.sub(r"_+", "_", safe) # Collapse consecutive underscores return safe.lower() diff --git a/test/components/lif/string_utils/test_core.py b/test/components/lif/string_utils/test_core.py index da55029..d40cd87 100644 --- a/test/components/lif/string_utils/test_core.py +++ b/test/components/lif/string_utils/test_core.py @@ -1,5 +1,88 @@ -from lif.string_utils import core +from datetime import date, datetime +from lif.string_utils import ( + safe_identifier, + to_pascal_case, + to_snake_case, + to_camel_case, + dict_keys_to_snake, + dict_keys_to_camel, + convert_dates_to_strings, + to_value_enum_name, +) -def test_sample(): - assert core is not None + +class TestSafeIdentifier: + def test_special_chars_replaced(self): + assert safe_identifier("First Name") == "first_name" + assert safe_identifier("first-name") == "first_name" + assert safe_identifier("first$name") == "first_name" + + def test_leading_digit_prefixed(self): + assert safe_identifier("123abc") == "_123abc" + + def test_camel_case_boundaries(self): + assert safe_identifier("CamelCase") == "camel_case" + assert safe_identifier("camelCaseABC") == "camel_case_abc" + + def test_consecutive_underscores_collapsed(self): + """Regression: CamelCase splitting + special chars should not produce double underscores.""" + assert safe_identifier("some--thing") == "some_thing" + assert safe_identifier("A__B") == "a_b" + + +class TestToPascalCase: + def test_from_snake_case(self): + assert to_pascal_case("hello_world") == "HelloWorld" + + def test_multiple_parts(self): + assert to_pascal_case("hello", "world") == "HelloWorld" + + +class TestToSnakeCase: + def test_from_camel_and_pascal(self): + assert to_snake_case("CamelCase") == "camel_case" + assert to_snake_case("camelCase") == "camel_case" + + def test_acronym_boundaries(self): + """Acronyms like HTTP and ID should split correctly at boundaries.""" + assert to_snake_case("HTTPServerID") == "http_server_id" + + +class TestToCamelCase: + def test_from_snake_case(self): + assert to_camel_case("hello_world") == "helloWorld" + + def test_lowercases_first_letter(self): + assert to_camel_case("HelloWorld") == "helloWorld" + + +class TestDictKeyTransforms: + def test_nested_keys_to_snake(self): + """Recursion through nested dicts and lists.""" + data = {"FirstName": "Alice", "Address": {"zipCode": 12345}, "items": [{"itemID": 1}]} + out = dict_keys_to_snake(data) + assert out == {"first_name": "Alice", "address": {"zip_code": 12345}, "items": [{"item_id": 1}]} + + def test_nested_keys_to_camel(self): + """Recursion through nested dicts and lists.""" + data = {"first_name": "Bob", "address": {"zip_code": 12345}, "items": [{"item_id": 1}]} + out = dict_keys_to_camel(data) + assert out == {"firstName": "Bob", "address": {"zipCode": 12345}, "items": [{"itemId": 1}]} + + +class TestConvertDatesToStrings: + def test_nested_dates_and_datetimes(self): + """Type dispatch: date and datetime converted, other types preserved.""" + d = date(2020, 1, 2) + dt = datetime(2020, 1, 2, 3, 4, 5) + obj = {"when": d, "arr": [dt, {"n": 1}]} + out = convert_dates_to_strings(obj) + assert out == {"when": d.isoformat(), "arr": [dt.isoformat(), {"n": 1}]} + + +class TestToValueEnumName: + def test_special_chars_and_leading_digits(self): + assert to_value_enum_name("in progress") == "IN_PROGRESS" + assert to_value_enum_name("done!") == "DONE_" + assert to_value_enum_name("123start") == "_123START"