Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). Version numb
- **MINOR**: New features that are backward-compatible.
- **PATCH**: Bug fixes or minor changes that do not affect backward compatibility.

## [Unreleased]

### Added
- **New Read Commands:** `get_plans`, `get_plan`, `get_suites`, `get_cases`, and `get_case` for retrieving test plans, suites, and cases from TestRail as JSON output. Supports environment variables and all standard authentication flags. (Issue #401)

## [1.13.3]

_released 03-05-2026
Expand Down
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The TestRail CLI currently supports:
- **Auto-generating test cases from OpenAPI specifications**
- **Creating new test runs for results to be uploaded to**
- **Managing project labels for better organization and categorization**
- **Reading plans, suites, and cases from TestRail as JSON**

To see further documentation about the TestRail CLI, please refer to the
[TestRail CLI documentation pages](https://support.gurock.com/hc/en-us/articles/7146548750868-TestRail-CLI)
Expand Down Expand Up @@ -1837,6 +1838,78 @@ expand your test cases to cover specific business logic and workflows.
|---------------------------------------|---------------------------------------------------|
| `VERB /path -> status_code (summary)` | `GET /pet/{petId} -> 200 (Successful operation) ` |

Reading data from TestRail
-----------------

The TestRail CLI provides read-only commands to retrieve plans, suites, and cases from TestRail as JSON.
These commands output JSON directly to stdout, making them easy to integrate with other tools
via piping (e.g., `jq`).

### Commands

| Command | Description |
|---------|-------------|
| `get_plans` | List all test plans for a project |
| `get_plan` | Get a single test plan by ID |
| `get_suites` | List all test suites for a project |
| `get_cases` | List test cases for a project and suite |
| `get_case` | Get a single test case by ID |

### Usage Examples

**List all test plans for a project:**
```shell
trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \
get_plans --project-id 1
```

**Get a single test plan:**
```shell
trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \
get_plan --plan-id 42
```

**List all test suites for a project:**
```shell
trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \
get_suites --project-id 1
```

**List test cases (with optional section filter):**
```shell
# All cases in a suite
trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \
get_cases --project-id 1 --suite-id 3

# Cases in a specific section
trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \
get_cases --project-id 1 --suite-id 3 --section-id 10
```

**Get a single test case:**
```shell
trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \
get_case --case-id 5001
```

**Using environment variables instead of flags:**
```shell
export TR_CLI_HOST=https://example.testrail.io
export TR_CLI_USERNAME=user@example.com
export TR_CLI_KEY=YOUR_API_KEY

trcli get_plans --project-id 1
trcli get_case --case-id 5001
```

**Piping output to jq:**
```shell
trcli get_cases --project-id 1 --suite-id 3 | jq '.[].title'
```

All read commands output a JSON array or object to stdout. Errors and warnings are printed to stderr,
so they will not interfere with piped JSON output.

Parameter sources
-----------------
You can choose to set parameters from different sources, like a default config file,
Expand Down
57 changes: 57 additions & 0 deletions tests/test_api_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1267,3 +1267,60 @@ def test_cache_stats(self, api_request_handler: ApiRequestHandler, requests_mock
assert stats["miss_count"] == 1
assert stats["hit_count"] == 1
assert stats["hit_rate"] == 50.0 # 1 hit out of 2 total requests

@pytest.mark.api_handler
def test_get_plans_returns_paginated_list(self, api_request_handler: ApiRequestHandler, requests_mock):
project_id = 1
mocked_response = {
"offset": 0,
"limit": 250,
"size": 2,
"_links": {"next": None, "prev": None},
"plans": [
{"id": 10, "name": "Plan A"},
{"id": 11, "name": "Plan B"},
],
}
requests_mock.get(create_url(f"get_plans/{project_id}"), json=mocked_response)
plans, error = api_request_handler.get_plans(project_id)
assert plans == [{"id": 10, "name": "Plan A"}, {"id": 11, "name": "Plan B"}]
assert error == ""

@pytest.mark.api_handler
def test_get_plans_cache_hit(self, api_request_handler: ApiRequestHandler, requests_mock):
project_id = 1
mocked_response = {
"offset": 0,
"limit": 250,
"size": 1,
"_links": {"next": None, "prev": None},
"plans": [{"id": 10, "name": "Plan A"}],
}
requests_mock.get(create_url(f"get_plans/{project_id}"), json=mocked_response)
# First call — cache miss
api_request_handler.get_plans(project_id)
# Second call — should be served from cache
plans, error = api_request_handler.get_plans(project_id)
assert plans == [{"id": 10, "name": "Plan A"}]
assert error == ""
# Verify only one HTTP request was made (second was cached)
assert requests_mock.call_count == 1
# Verify correct cache key was used
stats = api_request_handler._cache.get_stats()
assert stats["hit_count"] == 1

@pytest.mark.api_handler
def test_get_plan_returns_single_plan(self, api_request_handler: ApiRequestHandler, requests_mock):
plan_id = 42
plan_dict = {"id": 42, "name": "Release Plan", "entries": []}
requests_mock.get(create_url(f"get_plan/{plan_id}"), json=plan_dict)
result, error = api_request_handler.get_plan(plan_id)
assert result == plan_dict
assert error == ""

@pytest.mark.api_handler
def test_get_plan_returns_error_message(self, api_request_handler: ApiRequestHandler, requests_mock):
plan_id = 999
requests_mock.get(create_url(f"get_plan/{plan_id}"), status_code=400, json={"error": "Field :plan_id is not a valid test plan."})
result, error = api_request_handler.get_plan(plan_id)
assert error != ""
105 changes: 105 additions & 0 deletions tests/test_cmd_get_case.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from unittest import mock

import pytest
from click.testing import CliRunner

from trcli.api.api_client import APIClientResult
from trcli.cli import cli as trcli_cli


class TestCmdGetCase:
"""Tests for the get_case CLI command."""

BASE_ARGS = [
"-h", "https://test.testrail.com",
"-u", "user@example.com",
"-p", "password123",
"get_case",
"--case-id", "99",
]

@mock.patch("trcli.commands.cmd_get_case._create_api_client")
def test_happy_path_returns_json(self, mock_create_client):
"""Successful API response prints JSON to stdout."""
case_data = {"id": 99, "title": "My Test Case", "section_id": 5}
mock_client = mock_create_client.return_value
mock_client.send_get.return_value = APIClientResult(
status_code=200, response_text=case_data, error_message=""
)

runner = CliRunner()
result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False)

assert result.exit_code == 0
assert '"My Test Case"' in result.output
assert '"id": 99' in result.output
mock_client.send_get.assert_called_once_with("get_case/99")

@mock.patch("trcli.commands.cmd_get_case._create_api_client")
def test_api_error_message_exits_with_code_1(self, mock_create_client):
"""API error message is output with exit code 1."""
mock_client = mock_create_client.return_value
mock_client.send_get.return_value = APIClientResult(
status_code=-1, response_text="", error_message="Connection refused"
)

runner = CliRunner()
result = runner.invoke(trcli_cli, self.BASE_ARGS)

assert result.exit_code == 1
assert "Connection refused" in result.output

@mock.patch("trcli.commands.cmd_get_case._create_api_client")
def test_api_non_200_status_exits_with_code_1(self, mock_create_client):
"""Non-200 status code prints error with exit code 1."""
mock_client = mock_create_client.return_value
mock_client.send_get.return_value = APIClientResult(
status_code=404, response_text="", error_message=""
)

runner = CliRunner()
result = runner.invoke(trcli_cli, self.BASE_ARGS)

assert result.exit_code == 1
assert "404" in result.output

def test_missing_case_id_exits_nonzero(self):
"""Missing --case-id triggers a Click error."""
args = [
"-h", "https://test.testrail.com",
"-u", "user@example.com",
"-p", "password123",
"get_case",
]
runner = CliRunner()
result = runner.invoke(trcli_cli, args)

assert result.exit_code != 0

def test_missing_host_exits_nonzero(self):
"""Missing -h triggers an error."""
args = [
"-u", "user@example.com",
"-p", "password123",
"get_case",
"--case-id", "99",
]
runner = CliRunner()
result = runner.invoke(trcli_cli, args)

assert result.exit_code == 1
assert "--host is required" in result.output

def test_missing_password_and_key_exits_nonzero(self):
"""Missing both -p and -k triggers an error."""
args = [
"-h", "https://test.testrail.com",
"-u", "user@example.com",
"get_case",
"--case-id", "99",
]
runner = CliRunner()
result = runner.invoke(trcli_cli, args)

assert result.exit_code == 1
assert "--password or --key is required" in result.output
Loading