# Issue #15: テストテンプレート自動生成

**Phase:** 1b
**優先度:** 中
**依存関係:** #09
**見積もり:** 0.5日

---

## 概要

`railway new node`でノード作成時に、対応するテストファイルを自動生成する。
TDDを促進するため、最初からテストが用意されている状態にする。

---

## TDD実装手順

### Step 1: Red（テストを書く）

```python
# tests/unit/cli/test_test_template.py
"""Tests for test template generation."""
import pytest
from pathlib import Path
import tempfile
import os
from typer.testing import CliRunner


runner = CliRunner()


class TestNodeTestGeneration:
    """Test test file generation for nodes."""

    def test_new_node_creates_test_file(self):
        """Should create test file when creating node."""
        from railway.cli.main import app

        with tempfile.TemporaryDirectory() as tmpdir:
            # Create project structure
            (Path(tmpdir) / "src" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "tests" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "src" / "nodes" / "__init__.py").touch()
            os.chdir(tmpdir)

            runner.invoke(app, ["new", "node", "fetch_data"])

            test_file = Path(tmpdir) / "tests" / "nodes" / "test_fetch_data.py"
            assert test_file.exists()

    def test_test_file_imports_node(self):
        """Should import the node in test file."""
        from railway.cli.main import app

        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "src" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "tests" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "src" / "nodes" / "__init__.py").touch()
            os.chdir(tmpdir)

            runner.invoke(app, ["new", "node", "process_data"])

            test_content = (Path(tmpdir) / "tests" / "nodes" / "test_process_data.py").read_text()
            assert "from src.nodes.process_data import" in test_content

    def test_test_file_has_test_class(self):
        """Should have test class."""
        from railway.cli.main import app

        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "src" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "tests" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "src" / "nodes" / "__init__.py").touch()
            os.chdir(tmpdir)

            runner.invoke(app, ["new", "node", "my_node"])

            test_content = (Path(tmpdir) / "tests" / "nodes" / "test_my_node.py").read_text()
            assert "class Test" in test_content

    def test_test_file_has_success_test(self):
        """Should have success test case."""
        from railway.cli.main import app

        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "src" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "tests" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "src" / "nodes" / "__init__.py").touch()
            os.chdir(tmpdir)

            runner.invoke(app, ["new", "node", "test_node"])

            test_content = (Path(tmpdir) / "tests" / "nodes" / "test_test_node.py").read_text()
            assert "def test_" in test_content
            assert "success" in test_content.lower()

    def test_test_file_has_error_test(self):
        """Should have error test case."""
        from railway.cli.main import app

        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "src" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "tests" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "src" / "nodes" / "__init__.py").touch()
            os.chdir(tmpdir)

            runner.invoke(app, ["new", "node", "error_node"])

            test_content = (Path(tmpdir) / "tests" / "nodes" / "test_error_node.py").read_text()
            assert "error" in test_content.lower()

    def test_test_file_uses_pytest(self):
        """Should use pytest conventions."""
        from railway.cli.main import app

        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "src" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "tests" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "src" / "nodes" / "__init__.py").touch()
            os.chdir(tmpdir)

            runner.invoke(app, ["new", "node", "pytest_node"])

            test_content = (Path(tmpdir) / "tests" / "nodes" / "test_pytest_node.py").read_text()
            assert "import pytest" in test_content

    def test_test_file_has_mock_imports(self):
        """Should import mock utilities."""
        from railway.cli.main import app

        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "src" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "tests" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "src" / "nodes" / "__init__.py").touch()
            os.chdir(tmpdir)

            runner.invoke(app, ["new", "node", "mock_node"])

            test_content = (Path(tmpdir) / "tests" / "nodes" / "test_mock_node.py").read_text()
            assert "mock" in test_content.lower() or "Mock" in test_content


class TestTestDirectoryCreation:
    """Test tests directory creation."""

    def test_creates_tests_dir_if_missing(self):
        """Should create tests/nodes/ if missing."""
        from railway.cli.main import app

        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "src" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "src" / "nodes" / "__init__.py").touch()
            # No tests/ directory
            os.chdir(tmpdir)

            runner.invoke(app, ["new", "node", "new_node"])

            assert (Path(tmpdir) / "tests" / "nodes").exists()
            assert (Path(tmpdir) / "tests" / "nodes" / "test_new_node.py").exists()


class TestExistingTestPreservation:
    """Test that existing tests are not overwritten."""

    def test_does_not_overwrite_existing_test(self):
        """Should not overwrite existing test file."""
        from railway.cli.main import app

        with tempfile.TemporaryDirectory() as tmpdir:
            (Path(tmpdir) / "src" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "tests" / "nodes").mkdir(parents=True)
            (Path(tmpdir) / "src" / "nodes" / "__init__.py").touch()

            # Create existing test
            existing_test = Path(tmpdir) / "tests" / "nodes" / "test_existing.py"
            existing_test.write_text("# My custom test\ndef test_custom(): pass")
            os.chdir(tmpdir)

            # Force create node (overwrites node, but not test)
            runner.invoke(app, ["new", "node", "existing", "--force"])

            test_content = existing_test.read_text()
            assert "# My custom test" in test_content
```

```bash
# 実行して失敗を確認
pytest tests/unit/cli/test_test_template.py -v
# Expected: PASSED or partial
```

### Step 2: Green（テストテンプレートの確認・更新）

```python
# railway/cli/new.py の _create_node_test 関数を更新

def _create_node_test(name: str) -> None:
    """Create test file for node."""
    tests_dir = Path.cwd() / "tests" / "nodes"
    if not tests_dir.exists():
        tests_dir.mkdir(parents=True)

    test_file = tests_dir / f"test_{name}.py"
    if test_file.exists():
        return  # Don't overwrite existing tests

    # Generate class name from node name
    class_name = "".join(word.title() for word in name.split("_"))

    content = f'''"""Tests for {name} node."""
import pytest
from unittest.mock import patch, MagicMock

from src.nodes.{name} import {name}


class Test{class_name}:
    """Test suite for {name} node."""

    def test_{name}_success(self):
        """Test normal case: should process data successfully."""
        # Arrange
        input_data = {{"key": "value"}}

        # Act
        result = {name}(input_data)

        # Assert
        assert result is not None
        # TODO: Add specific assertions

    def test_{name}_with_empty_input(self):
        """Test edge case: empty input."""
        # Arrange
        input_data = {{}}

        # Act
        result = {name}(input_data)

        # Assert
        assert result is not None
        # TODO: Add specific assertions

    def test_{name}_error_handling(self):
        """Test error case: should handle errors appropriately."""
        # Arrange
        invalid_input = None

        # Act & Assert
        # TODO: Implement error test
        # with pytest.raises(ValueError):
        #     {name}(invalid_input)
        pass


class Test{class_name}WithMocks:
    """Test {name} with external dependencies mocked."""

    def test_{name}_with_mocked_dependency(self):
        """Test with mocked external dependency."""
        # TODO: Add mock tests if node has external dependencies
        # with patch("src.nodes.{name}.external_api") as mock_api:
        #     mock_api.return_value = {{"data": "mocked"}}
        #     result = {name}({{}})
        #     assert result is not None
        pass


@pytest.fixture
def sample_input():
    """Sample input data for testing."""
    return {{
        "id": 1,
        "name": "test",
        "value": 100,
    }}


def test_{name}_with_fixture(sample_input):
    """Test using pytest fixture."""
    result = {name}(sample_input)
    assert result is not None
'''
    test_file.write_text(content)
```

---

## 完了条件

- [ ] `railway new node`でテストファイルが自動生成される
- [ ] テストファイルがノードをインポートする
- [ ] テストクラスが生成される
- [ ] 成功ケースのテストが含まれる
- [ ] エラーケースのテストが含まれる
- [ ] pytestとmockのインポートが含まれる
- [ ] tests/nodes/が存在しない場合は作成される
- [ ] 既存のテストファイルは上書きされない
- [ ] テストカバレッジ90%以上

---

## Phase 1b 完了

これでPhase 1bのすべてのIssueが完了しました。

## 次のPhase

- Phase 1c: 高度な機能（#16〜#20）
