diff --git a/pytest_testrail/__init__.py b/pytest_testrail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytest_testrail/_category.py b/pytest_testrail/_category.py new file mode 100644 index 0000000..c857921 --- /dev/null +++ b/pytest_testrail/_category.py @@ -0,0 +1,974 @@ +""" +TestRail API categories +""" +import json +from typing import List + +from pytest_testrail.model.case import Case +from pytest_testrail.helper import TestRailError +from pytest_testrail.model.case_type import CaseType +from pytest_testrail.model.plan import Plan, Entry +from pytest_testrail.model.priority import Priority +from pytest_testrail.model.project import Project +from pytest_testrail.model.result import Result +from pytest_testrail.model.section import Section +from pytest_testrail.model.status import Status +from pytest_testrail.model.suite import Suite +from pytest_testrail.model.templates import Template +from pytest_testrail.model.test import Test + + +class BaseCategory: + + def __init__(self, session): + self._session = session + + +class Cases(BaseCategory): + + def get_case(self, case_id: int) -> Case: + """ + http://docs.gurock.com/testrail-api2/reference-cases#get_case + + Returns an existing test case. + :param case_id: The ID of the test case + :return: response + """ + response = self._session.request('GET', f'get_case/{case_id}') + return Case(response) + + def get_cases(self, project_id: int, **kwargs) -> List[Case]: + """ + http://docs.gurock.com/testrail-api2/reference-cases#get_cases + + Returns a list of test cases for a test suite or specific section in a test suite. + :param project_id: The ID of the project + :key suite_id: int - The ID of the test suite (optional if the project is operating in single suite mode) + :key section_id: int - The ID of the section (optional) + :return: response + """ + response = self._session.request('GET', f'get_cases/{project_id}', params=kwargs) + return [Case(rsp) for rsp in response] + + def add_case(self, section_id: int, case: Case) -> Case: + """ + http://docs.gurock.com/testrail-api2/reference-cases#add_case + + Creates a new test case. + :param section_id: The ID of the section the test case should be added to + :param case: The case object to be added (required) + + Custom fields are supported as well and must be submitted with their system name, prefixed with 'custom_', e.g.: + { + .. + "custom_preconds": "These are the preconditions for a test case" + .. + } + :return: response + """ + data = case.raw_data() + response = self._session.request('POST', f'add_case/{section_id}', json=data) + if 'error' in response: + raise TestRailError('Case add failed with error: %s' % response['error']) + return Case(response) + + def update_case(self, case_id: int, case: Case) -> Case: + """ + http://docs.gurock.com/testrail-api2/reference-cases#update_case + + Updates an existing test case (partial updates are supported, i.e. + you can submit and update specific fields only). + :param case_id: The ID of the test case (required) + :param case: The Case object to be added (required) + :return: response + """ + data = case.raw_data() + response = self._session.request('POST', f'update_case/{case_id}', json=data) + if 'error' in response: + raise TestRailError('Case update failed with error: %s' % response['error']) + return Case(response) + + def delete_case(self, case_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-cases#delete_case + + Deletes an existing test case. + :param case_id: The ID of the test case + :return: response + """ + return self._session.request('POST', f'delete_case/{case_id}') + + +class CaseFields(BaseCategory): + + def get_case_fields(self) -> List[dict]: + """ + http://docs.gurock.com/testrail-api2/reference-cases-fields#get_case_fields + + Returns a list of available test case custom fields. + :return: response + """ + return self._session.request('GET', 'get_case_fields') + + def add_case_field(self, typ: str, name: str, label: str, **kwargs): + """ + http://docs.gurock.com/testrail-api2/reference-cases-fields#add_case_field + + Creates a new test case custom field. + :param typ: str - The type identifier for the new custom field (required). The following types are supported: + String, Integer, Text, URL, Checkbox, Dropdown, User, Date, Milestone, Steps, Multiselect + You can pass the number of the type as well as the word, e.g. "5", "string", "String", "Dropdown", + "12". The numbers must be sent as a string e.g {type: "5"} not {type: 5}, + otherwise you will get a 400 (Bad Request) response. + :param name: str - The name for new the custom field (required) + :param label: str - The label for the new custom field (required) + :key description: str - The description for the new custom field + :key include_all: bool - Set flag to true if you want the new custom field included for all templates. + Otherwise (false) specify the ID's of templates to be included as the next + parameter (template_ids) + :key template_ids: list - ID's of templates new custom field will apply to if include_all is set to false + :key configs: dict - An object wrapped in an array with two default keys, 'context' and 'options' + :return: response + """ + data = dict(type=typ, name=name, label=label, **kwargs) + return self._session.request('POST', 'add_case_field', json=data) + + +class CaseTypes(BaseCategory): + + def get_case_types(self) -> List[CaseType]: + """ + http://docs.gurock.com/testrail-api2/reference-cases-types#get_case_types + + Returns a list of available case types. + :return: response + """ + response = self._session.request('GET', 'get_case_types') + return [CaseType(obj) for obj in response] + + +class Configurations(BaseCategory): + + def get_configs(self, project_id: int) -> List[dict]: + """ + http://docs.gurock.com/testrail-api2/reference-configs#get_configs + + :param project_id: The ID of the project + :return: response + """ + return self._session.request('GET', f'get_configs/{project_id}') + + def add_config_group(self, project_id: int, name: str): + """ + http://docs.gurock.com/testrail-api2/reference-configs#add_config_group + + :param project_id: The ID of the project the configuration group should be added to + :param name: The name of the configuration group (required) + :return: response + """ + return self._session.request('POST', f'add_config_group/{project_id}', json={'name': name}) + + def add_config(self, config_group_id: int, name: str): + """ + http://docs.gurock.com/testrail-api2/reference-configs#add_config + + :param config_group_id: The ID of the configuration group the configuration should be added to + :param name: The name of the configuration (required) + :return: response + """ + return self._session.request('POST', f'add_config/{config_group_id}', json={'name': name}) + + def update_config_group(self, config_group_id: int, name: str): + """ + http://docs.gurock.com/testrail-api2/reference-configs#update_config_group + + :param config_group_id: The ID of the configuration group + :param name: The name of the configuration group + :return: response + """ + return self._session.request('POST', f'update_config_group/{config_group_id}', json={'name': name}) + + def update_config(self, config_id: int, name: str): + """ + http://docs.gurock.com/testrail-api2/reference-configs#update_config + + :param config_id: The ID of the configuration + :param name: The name of the configuration + :return: response + """ + return self._session.request('POST', f'update_config/{config_id}', json={'name': name}) + + def delete_config_group(self, config_group_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-configs#delete_config_group + + :param config_group_id: The ID of the configuration group + :return: response + """ + return self._session.request('POST', f'delete_config_group/{config_group_id}') + + def delete_config(self, config_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-configs#delete_config + + :param config_id: The ID of the configuration + :return: response + """ + return self._session.request('POST', f'delete_config/{config_id}') + + +class Milestones(BaseCategory): + + def get_milestone(self, milestone_id: int) -> dict: + """ + http://docs.gurock.com/testrail-api2/reference-milestones#get_milestone + + Returns an existing milestone. + :param milestone_id: The ID of the milestone + :return: response + """ + return self._session.request('GET', f'get_milestone/{milestone_id}') + + def get_milestones(self, project_id: int, **kwargs) -> List[dict]: + """ + http://docs.gurock.com/testrail-api2/reference-milestones#get_milestones + + :param project_id: The ID of the project + :key is_completed: 1 to return completed milestones only. 0 to return open (active/upcoming) + milestones only (available since TestRail 4.0). + :key is_started: 1 to return started milestones only. 0 to return upcoming milestones only + (available since TestRail 5.3). + :return: response + """ + return self._session.request('GET', f'get_milestones/{project_id}', params=kwargs) + + def add_milestone(self, project_id: int, name: str, **kwargs) -> dict: + """ + http://docs.gurock.com/testrail-api2/reference-milestones#add_milestone + + :param project_id: The ID of the project the milestone should be added to + :param name: str - The name of the milestone (required) + + :key description: str - The description of the milestone + :key due_on: int - The due date of the milestone (as UNIX timestamp) + :key parent_id: int - The ID of the parent milestone, if any (for sub-milestones) + (available since TestRail 5.3) + :key start_on: int - The scheduled start date of the milestone (as UNIX timestamp) + (available since TestRail 5.3) + :return: response + """ + data = dict(name=name, **kwargs) + return self._session.request('POST', f'add_milestone/{project_id}', json=data) + + def update_milestone(self, milestone_id: int, **kwargs) -> dict: + """ + http://docs.gurock.com/testrail-api2/reference-milestones#update_milestone + + :param milestone_id: The ID of the milestone + :key is_completed: bool - True if a milestone is considered completed and false otherwise + :key is_started: bool - True if a milestone is considered started and false otherwise + :key parent_id: int - The ID of the parent milestone, if any (for sub-milestones) + (available since TestRail 5.3) + :key start_on: int - The scheduled start date of the milestone (as UNIX timestamp) + (available since TestRail 5.3) + :return: response + """ + return self._session.request('POST', f'update_milestone/{milestone_id}', json=kwargs) + + def delete_milestone(self, milestone_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-milestones#delete_milestone + + :param milestone_id: The ID of the milestone + :return: response + """ + return self._session.request('POST', f'delete_milestone/{milestone_id}') + + +class Plans(BaseCategory): + + def get_plan(self, plan_id: int) -> Plan: + """ + http://docs.gurock.com/testrail-api2/reference-plans#get_plan + + Returns an existing test plan. + :param plan_id: The ID of the test plan + :return: response + """ + response = self._session.request('GET', f'get_plan/{plan_id}') + return Plan(response) + + def get_plans(self, project_id: int, **kwargs) -> List[Plan]: + """ + http://docs.gurock.com/testrail-api2/reference-plans#get_plans + + Returns a list of test plans for a project. + :param project_id: The ID of the project + :param kwargs: filters + :key created_after: int - Only return test plans created after this date (as UNIX timestamp). + :key created_before: int - Only return test plans created before this date (as UNIX timestamp). + :key created_by: int(list) - A comma-separated list of creators (user IDs) to filter by. + :key is_completed: int - 1 to return completed test plans only. 0 to return active test plans only. + :key limit/offset: int - Limit the result to :limit test plans. Use :offset to skip records. + :key milestone_id: int(list) - A comma-separated list of milestone IDs to filter by. + :return: response + """ + response = self._session.request('GET', f'get_plans/{project_id}', params=kwargs) + return [Plan(obj) for obj in response] + + def add_plan(self, project_id: int, plan: Plan) -> Plan: + """ + http://docs.gurock.com/testrail-api2/reference-plans#add_plan + + Creates a new test plan. + :param project_id: The ID of the project the test plan should be added to + :param plan: The Plan to be added (required) + :return: response + """ + data = plan.raw_data() + response = self._session.request('POST', f'add_plan/{project_id}', json=data) + return Plan(response) + + def add_plan_entry(self, plan_id: int, entry: Entry) -> Entry: + """ + http://docs.gurock.com/testrail-api2/reference-plans#add_plan_entry + + Adds one or more new test runs to a test plan. + :param plan_id: The ID of the plan the test runs should be added to + :param entry: The PlanEntry of the test suite for the test run(s) to be added (required) + :return: response + """ + data = entry.raw_data() + response = self._session.request('POST', f'add_plan_entry/{plan_id}', json=data) + return Entry(response) + + def update_plan(self, plan: Plan) -> Plan: + """ + http://docs.gurock.com/testrail-api2/reference-plans#update_plan + + Updates an existing test plan (partial updates are supported, + i.e. you can submit and update specific fields only). + :param plan: The Plan to be updated (required) + :return: response + """ + data = plan.raw_data() + response = self._session.request('POST', f'update_plan/{plan.id}', json=data) + return Plan(response) + + def update_plan_entry(self, plan_id: int, entry: Entry) -> Entry: + """ + http://docs.gurock.com/testrail-api2/reference-plans#update_plan_entry + + Updates one or more existing test runs in a plan (partial updates are supported, + i.e. you can submit and update specific fields only). + :param plan_id: The ID of the test plan + :param entry: The Entry to be added (required) + :return: response + """ + data = entry.raw_data() + response = self._session.request('POST', f'update_plan_entry/{plan_id}/{entry.id}', json=data) + return Entry(response) + + def close_plan(self, plan_id: int) -> Plan: + """ + http://docs.gurock.com/testrail-api2/reference-plans#close_plan + + Closes an existing test plan and archives its test runs & results. + :param plan_id: The ID of the test plan + :return: response + """ + response = self._session.request('POST', f'close_plan/{plan_id}') + return Plan(response) + + def delete_plan(self, plan_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-plans#delete_plan + + Deletes an existing test plan. + :param plan_id: The ID of the test plan + :return: response + """ + return self._session.request('POST', f'delete_plan/{plan_id}') + + def delete_plan_entry(self, plan_id: int, entry_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-plans#delete_plan_entry + + Deletes one or more existing test runs from a plan. + :param plan_id: The ID of the test plan + :param entry_id: The ID of the test plan entry (note: not the test run ID) + :return: response + """ + return self._session.request('POST', f'delete_plan_entry/{plan_id}/{entry_id}') + + +class Priorities(BaseCategory): + + def get_priorities(self) -> List[Priority]: + """ + http://docs.gurock.com/testrail-api2/reference-priorities#get_priorities + + Returns a list of available priorities. + :return: response + """ + response = self._session.request('GET', 'get_priorities') + return [Priority(obj) for obj in response] + + +class Projects(BaseCategory): + + def get_project(self, project_id: int) -> Project: + """ + http://docs.gurock.com/testrail-api2/reference-projects#get_project + + Returns an existing project. + + :param project_id: The ID of the project + :return: response + """ + response = self._session.request('GET', f'get_project/{project_id}') + return Project(response) + + def get_projects(self, **kwargs) -> List[Project]: + """ + http://docs.gurock.com/testrail-api2/reference-projects#get_projects + + Returns the list of available projects. + + :param kwargs: filter + :key is_completed: int - 1 to return completed projects only. 0 to return active projects only. + :return: response + """ + response = self._session.request('GET', 'get_projects', params=kwargs) + return [Project(obj) for obj in response] + + def add_project(self, project: Project) -> Project: + """ + http://docs.gurock.com/testrail-api2/reference-projects#add_project + + Creates a new project (admin status required). + + :param project: The Project object to be added (required) + :return: response + """ + data = project.raw_data() + response = self._session.request('POST', 'add_project', json=data) + if 'error' in response: + raise TestRailError('Project creation failed with error: %s' % response['error']) + return Project(response) + + def update_project(self, project: Project) -> Project: + """ + http://docs.gurock.com/testrail-api2/reference-projects#update_project + + Updates an existing project (admin status required; partial updates are supported, + i.e. you can submit and update specific fields only). + + :param project: The Project object to be updated (required) + :return: response + """ + data = project.raw_data() + response = self._session.request('POST', f'update_project/{project.id}', json=data) + if 'error' in response: + raise TestRailError('Project update failed with error: %s' % response['error']) + return Project(response) + + def delete_project(self, project_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-projects#delete_project + + Deletes an existing project (admin status required). + + :param project_id: The ID of the project + :return: response + """ + response = self._session.request('POST', f'delete_project/{project_id}') + if 'error' in response: + raise TestRailError('Project delete failed with error: %s' % response['error']) + return response + + +class Results(BaseCategory): + + def get_results(self, test_id: int, **kwargs) -> List[Result]: + """ + http://docs.gurock.com/testrail-api2/reference-results#get_results + + Returns a list of test results for a test. + + :param test_id: The ID of the test + :param kwargs: filters + :key limit/offset: int - Limit the result to :limit test results. Use :offset to skip records. + :key status_id: int(list) - A comma-separated list of status IDs to filter by. + :return: response + """ + return self._session.request('GET', f'get_results/{test_id}', params=kwargs) + + def get_results_for_case(self, run_id: int, case_id: int, **kwargs) -> List[Result]: + """ + http://docs.gurock.com/testrail-api2/reference-results#get_results_for_case + + Returns a list of test results for a test run and case combination. + + The difference to get_results is that this method expects a test run + test case instead of a test. + In TestRail, tests are part of a test run and the test cases are part of the related test suite. + So, when you create a new test run, TestRail creates a test for each test case found in the test suite + of the run. You can therefore think of a test as an “instance” of a test case which can have test results, + comments and a test status. Please also see TestRail's getting started guide for more details about the + differences between test cases and tests. + + :param run_id: The ID of the test run + :param case_id: The ID of the test case + :param kwargs: filters + :key limit/offset: int - Limit the result to :limit test results. Use :offset to skip records. + :key status_id: int(list) - A comma-separated list of status IDs to filter by. + :return: response + """ + return self._session.request('GET', f'get_results_for_case/{run_id}/{case_id}', params=kwargs) + + def get_results_for_run(self, run_id: int, **kwargs) -> List[Result]: + """ + http://docs.gurock.com/testrail-api2/reference-results#get_results_for_run + + Returns a list of test results for a test run. + Requires TestRail 4.0 or later. + + :param run_id: The ID of the test run + :param kwargs: filters + :key created_after: int - Only return test results created after this date (as UNIX timestamp). + :key created_before: int - Only return test results created before this date (as UNIX timestamp). + :key created_by: int(list) - A comma-separated list of creators (user IDs) to filter by. + :key limit/offset: int - Limit the result to :limit test results. Use :offset to skip records. + :key status_id: int(list) - A comma-separated list of status IDs to filter by. + :return: response + """ + return self._session.request('GET', f'get_results_for_run/{run_id}', params=kwargs) + + def add_result(self, result: Result) -> List[Result]: + """ + http://docs.gurock.com/testrail-api2/reference-results#add_result + + Adds a new test result, comment or assigns a test. + It's recommended to use add_results instead if you plan to add results for multiple tests. + + :param result: The Result object to be added + :return: response + """ + data = result.raw_data() + response = self._session.request('POST', f'add_result/{result.test_id}', json=data) + return [Result(obj) for obj in response] + + def add_result_for_case(self, run_id: int, case_id: int, result: Result) -> List[Result]: + """ + http://docs.gurock.com/testrail-api2/reference-results#add_result_for_case + + Adds a new test result, comment or assigns a test (for a test run and case combination). + It's recommended to use add_results_for_cases instead if you plan to add results for multiple test cases. + + The difference to add_result is that this method expects a test run + test case instead of a test. + In TestRail, tests are part of a test run and the test cases are part of the related test suite. + So, when you create a new test run, TestRail creates a test for each test case found in the test suite + of the run. You can therefore think of a test as an “instance” of a test case which can have test results, + comments and a test status. Please also see TestRail's getting started guide for more details about the + differences between test cases and tests. + + :param run_id: The ID of the test run + :param case_id: The ID of the test case + :param result: The Result object to be added + :return: response + """ + data = result.raw_data() + result = self._session.request('POST', f'add_result_for_case/{run_id}/{case_id}', json=data) + return [Result(obj) for obj in result] + + def add_results(self, run_id: int, results: List[Result]) -> List[Result]: + """ + http://docs.gurock.com/testrail-api2/reference-results#add_results + + This method expects an array of test results (via the 'results' field, please see below). + Each test result must specify the test ID and can pass in the same fields as add_result, + namely all test related system and custom fields. + + Please note that all referenced tests must belong to the same test run. + + :param run_id: The ID of the test run the results should be added to + :param results: List[Results] + This method expects an array of test results (via the 'results' field, please see below). + Each test result must specify the test ID and can pass in the same fields as add_result, + namely all test related system and custom fields. + + Please note that all referenced tests must belong to the same test run. + :return: response + """ + data = [ob.raw_data() for ob in results] + payload = {'results': list()} + for obj in data: + payload['results'].append(obj) + response = self._session.request('POST', f'add_results/{run_id}', json=payload) + return [Result(obj) for obj in response] + + def add_results_for_cases(self, run_id: int, results: List[Result]) -> List[Result]: + """ + http://docs.gurock.com/testrail-api2/reference-results#add_results_for_cases + + Adds one or more new test results, comments or assigns one or more tests (using the case IDs). + Ideal for test automation to bulk-add multiple test results in one step. + + Requires TestRail 3.1 or later + + :param run_id: The ID of the test run the results should be added to + :param results: List[Result] + This method expects an array of test results (via the 'results' field, please see below). + Each test result must specify the test case ID and can pass in the same fields as add_result, + namely all test related system and custom fields. + + The difference to add_results is that this method expects test case IDs instead of test IDs. + Please see add_result_for_case for details. + + Please note that all referenced tests must belong to the same test run. + :return: response + """ + data = json.dumps({'results': [ob.raw_data() for ob in results]}) + response = self._session.request('POST', f'add_results_for_cases/{run_id}', json=data) + return [Result(obj) for obj in response] + + +class ResultFields(BaseCategory): + + def get_result_fields(self) -> List[dict]: + """ + http://docs.gurock.com/testrail-api2/reference-results-fields#get_result_fields + + Returns a list of available test result custom fields. + + :return: response + """ + return self._session.request('GET', 'get_result_fields') + + +class Runs(BaseCategory): + + def get_run(self, run_id: int) -> dict: + """ + http://docs.gurock.com/testrail-api2/reference-runs#get_run + + Returns an existing test run. Please see get_tests for the list of included tests in this run. + + :param run_id: The ID of the test run + :return: response + """ + return self._session.request('GET', f'get_run/{run_id}') + + def get_runs(self, project_id: int, **kwargs) -> List[dict]: + """ + http://docs.gurock.com/testrail-api2/reference-runs#get_runs + + Returns a list of test runs for a project. Only returns those test runs that are not part of a test plan + (please see get_plans/get_plan for this). + + :param project_id: The ID of the project + :param kwargs: filters + :key created_after: int - Only return test runs created after this date (as UNIX timestamp). + :key created_before: int - Only return test runs created before this date (as UNIX timestamp). + :key created_by: int(list) - A comma-separated list of creators (user IDs) to filter by. + :key is_completed: int - 1 to return completed test runs only. 0 to return active test runs only. + :key limit/offset: int - Limit the result to :limit test runs. Use :offset to skip records. + :key milestone_id: int(list) - A comma-separated list of milestone IDs to filter by. + :key suite_id: int(list) - A comma-separated list of test suite IDs to filter by. + :return: response + """ + return self._session.request('GET', f'get_runs/{project_id}', params=kwargs) + + def add_run(self, project_id: int, **kwargs) -> dict: + """ + http://docs.gurock.com/testrail-api2/reference-runs#add_run + + Creates a new test run. + + :param project_id: The ID of the project the test run should be added to + :key suite_id: int - The ID of the test suite for the test run + (optional if the project is operating in single suite mode, required otherwise) + :key name: str - The name of the test run + :key description: str - The description of the test run + :key milestone_id: int - The ID of the milestone to link to the test run + :key assignedto_id: int - The ID of the user the test run should be assigned to + :key include_all: bool - True for including all test cases of the test suite and false for a + custom case selection (default: true) + :key case_ids: list - An array of case IDs for the custom case selection + :return: response + """ + return self._session.request('POST', f'add_run/{project_id}', json=kwargs) + + def update_run(self, run_id: int, **kwargs) -> dict: + """ + http://docs.gurock.com/testrail-api2/reference-runs#update_run + + Updates an existing test run (partial updates are supported, + i.e. you can submit and update specific fields only). + + :param run_id: The ID of the test run + :param kwargs: With the exception of the suite_id and assignedto_id fields, + this method supports the same POST fields as add_run. + :return: response + """ + return self._session.request('POST', f'update_run/{run_id}', json=kwargs) + + def close_run(self, run_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-runs#close_run + + Closes an existing test run and archives its tests & results. + + :param run_id: The ID of the test run + :return: response + """ + return self._session.request('POST', f'close_run/{run_id}') + + def delete_run(self, run_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-runs#delete_run + + Deletes an existing test run. + + :param run_id: The ID of the test run + :return: response + """ + return self._session.request('POST', f'delete_run/{run_id}') + + +class Sections(BaseCategory): + + def get_section(self, section_id: int) -> Section: + """ + http://docs.gurock.com/testrail-api2/reference-sections#get_section + + Returns an existing section. + + :param section_id: The ID of the section + :return: response + """ + response = self._session.request('GET', f'get_section/{section_id}') + return Section(response) + + def get_sections(self, project_id: int, suite_id: int) -> List[Section]: + """ + http://docs.gurock.com/testrail-api2/reference-sections#get_sections + + Returns a list of sections for a project and test suite. + + :param project_id: The ID of the project + :param suite_id: The ID of the test suite (optional if the project is operating in single suite mode) + :return: response + """ + response = self._session.request('GET', f'get_sections/{project_id}', params={'suite_id': suite_id}) + return [Section(rsp) for rsp in response] + + def add_section(self, project_id: int, section: Section) -> Section: + """ + http://docs.gurock.com/testrail-api2/reference-sections#add_section + + Creates a new section. + + :param project_id: The section to be created + :param section: The name of the section (required) + :return: response + """ + data = section.raw_data() + response = self._session.request('POST', f'add_section/{project_id}', json=data) + if 'error' in response: + raise TestRailError('Section creation failed with error: %s' % response['error']) + return Section(response) + + def update_section(self, section: Section) -> Section: + """ + http://docs.gurock.com/testrail-api2/reference-sections#update_section + + Updates an existing section (partial updates are supported, + i.e. you can submit and update specific fields only). + + :param section: The section to be updated + :return: response + """ + data = section.raw_data() + response = self._session.request('POST', f'update_section/{section.id}', json=data) + if 'error' in response: + raise TestRailError('Section update failed with error: %s' % response['error']) + return Section(response) + + def delete_section(self, section_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-sections#delete_section + + Deletes an existing section. + + :param section_id: The ID of the section + :return: response + """ + return self._session.request('POST', f'delete_section/{section_id}') + + +class Statuses(BaseCategory): + + def get_statuses(self) -> List[Status]: + """ + http://docs.gurock.com/testrail-api2/reference-statuses#get_statuses + + Returns a list of available test statuses. + + :return: response + """ + response = self._session.request('GET', 'get_statuses') + return [Status(obj) for obj in response] + + +class Suites(BaseCategory): + + def get_suite(self, suite_id: int) -> Suite: + """ + http://docs.gurock.com/testrail-api2/reference-suites#get_suite + + Returns an existing test suite. + + :param suite_id: The ID of the test suite + :return: response + """ + response = self._session.request('GET', f'get_suite/{suite_id}') + return Suite(response) + + def get_suites(self, project_id: int) -> List[Suite]: + """ + http://docs.gurock.com/testrail-api2/reference-suites#get_suites + + Returns a list of test suites for a project. + + :param project_id: The ID of the project + :return: response + """ + response = self._session.request('GET', f'get_suites/{project_id}') + return [Suite(obj) for obj in response] + + def add_suite(self, project_id: int, suite: Suite) -> Suite: + """ + http://docs.gurock.com/testrail-api2/reference-suites#add_suite + + Creates a new test suite. + + :param project_id: The ID of the project the test suite should be added to + :param suite: The Suite object to be added (required) + :return: response + """ + data = suite.raw_data() + response = self._session.request('POST', f'add_suite/{project_id}', json=data) + if 'error' in response: + raise TestRailError('Suite creation failed with error: %s' % response['error']) + return Suite(response) + + def update_suite(self, suite: Suite) -> Suite: + """ + http://docs.gurock.com/testrail-api2/reference-suites#update_suite + + Updates an existing test suite (partial updates are supported, + i.e. you can submit and update specific fields only). + + :param suite: The Suite object to be updated (required) + :return: response + """ + data = suite.raw_data() + response = self._session.request('POST', f'update_suite/{suite.id}', json=data) + if 'error' in response: + raise TestRailError('Suite update failed with error: %s' % response['error']) + return Suite(response) + + def delete_suite(self, suite_id: int): + """ + http://docs.gurock.com/testrail-api2/reference-suites#delete_suite + + Deletes an existing test suite. + + :param suite_id: The ID of the test suite + :return: response + """ + response = self._session.request('POST', f'delete_suite/{suite_id}') + if 'error' in response: + raise TestRailError('Suite delete failed with error: %s' % response['error']) + return response + + +class Templates(BaseCategory): + + def get_templates(self, project_id: int) -> List[Template]: + """ + http://docs.gurock.com/testrail-api2/reference-templates#get_templates + + Returns a list of available templates (requires TestRail 5.2 or later). + + :param project_id: The ID of the project + :return: response + """ + response = self._session.request('GET', f'get_templates/{project_id}') + return [Template(obj) for obj in response] + + +class Tests(BaseCategory): + + def get_test(self, test_id: int) -> Test: + """ + http://docs.gurock.com/testrail-api2/reference-tests#get_test + + Returns an existing test. + If you interested in the test results rather than the tests, please see get_results instead. + + :param test_id: The ID of the test + :return: response + """ + result = self._session.request('GET', f'get_test/{test_id}') + return Test(result) + + def get_tests(self, run_id: int, **kwargs) -> List[Test]: + """ + http://docs.gurock.com/testrail-api2/reference-tests#get_tests + + Returns a list of tests for a test run. + + :param run_id: The ID of the test run + :param kwargs: filters + :key status_id: int(list) - A comma-separated list of status IDs to filter by. + :return: response + """ + result = self._session.request('GET', f'get_tests/{run_id}', params=kwargs) + return [Test(obj) for obj in result] + + +class Users(BaseCategory): + + def get_user(self, user_id: int) -> dict: + """ + http://docs.gurock.com/testrail-api2/reference-users#get_user + + Returns an existing user. + + :param user_id: The ID of the user + :return: response + """ + return self._session.request('GET', f'get_user/{user_id}') + + def get_user_by_email(self, email: str) -> dict: + """ + http://docs.gurock.com/testrail-api2/reference-users#get_user_by_email + + Returns an existing user by his/her email address. + + :param email: The email address to get the user for + :return: response + """ + return self._session.request('GET', f'get_user_by_email', params={'email': email}) + + def get_users(self) -> List[dict]: + """ + http://docs.gurock.com/testrail-api2/reference-users#get_users + + Returns a list of users. + + :return: response + """ + return self._session.request('GET', 'get_users') diff --git a/pytest_testrail/_session.py b/pytest_testrail/_session.py new file mode 100644 index 0000000..62ff504 --- /dev/null +++ b/pytest_testrail/_session.py @@ -0,0 +1,67 @@ +import os +import sys + +import requests + +from pytest_testrail.exceptions import MissingCloudCredentialError + +# pylint: disable=import-error +if sys.version_info[0] == 2: + import ConfigParser as configparser +else: + import configparser + + +class Session: + __default_headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'TestRail API v: 2' + } + + def __init__(self, base_url: str = None, email: str = None, key: str = None, **kwargs): + email = email or self.__get_credential('email', ['TESTRAIL_EMAIL']) + key = key or self.__get_credential('key', ['TESTRAIL_KEY']) + base_url = base_url or self.__get_credential('url', ['TESTRAIL_URL']) + verify_ssl = bool(self.__get_credential('verify_ssl', ['TESTRAIL_VERIFY_URL'])) + + self.__base_url = f'{base_url}/index.php?/api/v2/' + self.__user = email + self.__password = key + self.__headers = kwargs.get('headers', self.__default_headers) + self.__verify_ssl = kwargs.get('verify_ssl', verify_ssl) + self.__timeout = kwargs.get('timeout', 5) + self.__session = requests.Session() + + @property + def __name(self): + return type(self).__name__ + + @property + def __config(self): + name = '.{0}'.format(self.__name.lower()) + config = configparser.ConfigParser() + config.read([name, os.path.join(os.path.expanduser('~'), name)]) + return config + + def __get_credential(self, key, envs): + try: + return self.__config.get('credentials', key) + except (configparser.NoSectionError, configparser.NoOptionError, KeyError): + for env in envs: + value = os.getenv(env) + if value: + return value + raise MissingCloudCredentialError(self.__name, key, envs) + + def request(self, method: str, src: str, **kwargs): + """ + Base request method + :param method: + :param src: + :param kwargs: + :return: response + """ + url = f'{self.__base_url}{src}' + response = self.__session.request(method, url, auth=(self.__user, self.__password), headers=self.__headers, + **kwargs) + return response.json() diff --git a/pytest_testrail/exceptions.py b/pytest_testrail/exceptions.py new file mode 100644 index 0000000..8bb2009 --- /dev/null +++ b/pytest_testrail/exceptions.py @@ -0,0 +1,15 @@ +import pytest + + +class MissingCloudCredentialError(pytest.UsageError): + def __init__(self, driver, key, envs): + super(MissingCloudCredentialError, self).__init__( + "{0} {1} must be set. Try setting one of the following " + "environment variables {2}, or see the documentation for " + "how to use a configuration file.".format(driver, key, envs) + ) + + +class InvalidOptionError(Exception): + def __init__(self, option): + Exception.__init__(self, 'Option %s is not a valid choice' % option) diff --git a/pytest_testrail/helper.py b/pytest_testrail/helper.py new file mode 100644 index 0000000..883b5c5 --- /dev/null +++ b/pytest_testrail/helper.py @@ -0,0 +1,41 @@ +import re +from datetime import timedelta + + +class TestRailError(Exception): + pass + + +class ContainerIter: + def __init__(self, objs): + self._objs = list(objs) + + def __len__(self): + return len(self._objs) + + def __getitem__(self, index): + return self._objs[index] + + +CUSTOM_METHODS_RE = re.compile(r'^custom_(\w+)') + + +def custom_methods(content): + matches = [CUSTOM_METHODS_RE.match(method) for method in content] + return dict({match.string: content[match.string] for match in matches if match}) + + +def testrail_duration_to_timedelta(duration): + span = __span() + timedelta_map = { + 'weeks': span(re.search(r'\d+w', duration)), + 'days': span(re.search(r'\d+d', duration)), + 'hours': span(re.search(r'\d+h', duration)), + 'minutes': span(re.search(r'\d+m', duration)), + 'seconds': span(re.search(r'\d+s', duration)) + } + return timedelta(**timedelta_map) + + +def __span(): + return lambda x: int(x.group(0)[:-1]) if x else 0 diff --git a/pytest_testrail/model/__init__.py b/pytest_testrail/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytest_testrail/model/case.py b/pytest_testrail/model/case.py new file mode 100644 index 0000000..4a82ad6 --- /dev/null +++ b/pytest_testrail/model/case.py @@ -0,0 +1,137 @@ +from datetime import datetime + +from pytest_testrail.helper import TestRailError, custom_methods + + +class Case(object): + def __init__(self, content=None): + self._content = content or dict() + self._custom_methods = custom_methods(self._content) + + def __getattr__(self, attr): + if attr in self._custom_methods: + return self._content.get(self._custom_methods[attr]) + raise AttributeError('"{}" object has no attribute "{}"'.format( + self.__class__.__name__, attr)) + + def __str__(self): + return self.title + + @property + def created_by(self): + return self._content.get('created_by') + + @property + def created_on(self): + return datetime.fromtimestamp(int(self._content.get('created_on'))) + + @property + def estimate(self): + return self._content.get('estimate') + + @estimate.setter + def estimate(self, value): + # TODO should have some logic to validate format of timespan + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['estimate'] = value + + @property + def estimated_forecast(self): + return self._content.get('estimated_forecast') + + @property + def id(self): + return self._content.get('id') + + @property + def milestone_id(self): + return self._content.get('milestone_id') + + @milestone_id.setter + def milestone_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an int') + self._content['milestone_id'] = value + + @property + def priority_id(self): + return self._content.get('priority_id') + + @priority_id.setter + def priority_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an int') + self._content['priority_id'] = value + + @property + def refs(self): + refs = self._content.get('refs') + return refs.split(',') if refs else list() + + @refs.setter + def refs(self, value): + if not isinstance(value, list): + raise TestRailError('input must be a list') + self._content['refs'] = ','.join(value) + + @property + def section_id(self): + return self._content.get('section_id') + + @section_id.setter + def section_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an int') + self._content['section_id'] = value + + @property + def suite_id(self): + return self._content.get('suite_id') + + @suite_id.setter + def suite_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an int') + self._content['suite_id'] = value + + @property + def template_id(self): + return self._content.get('template_id') + + @template_id.setter + def template_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an id') + self._content['template_id'] = value + + @property + def title(self): + return self._content.get('title') + + @title.setter + def title(self, value): + if not isinstance(value, (str, str)): + raise TestRailError('input must be a string') + self._content['title'] = value + + @property + def type_id(self): + return self._content.get('type_id') + + @type_id.setter + def type_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an id') + self._content['type_id'] = value + + @property + def updated_by(self): + return self._content.get('updated_by') + + @property + def updated_on(self): + return datetime.fromtimestamp(int(self._content.get('updated_on'))) + + def raw_data(self): + return self._content diff --git a/pytest_testrail/model/case_type.py b/pytest_testrail/model/case_type.py new file mode 100644 index 0000000..04828d5 --- /dev/null +++ b/pytest_testrail/model/case_type.py @@ -0,0 +1,18 @@ +class CaseType(object): + def __init__(self, content): + self._content = content + + def __str__(self): + return self.name + + @property + def id(self): + return self._content.get('id') + + @property + def is_default(self): + return self._content.get('is_default') + + @property + def name(self): + return self._content.get('name') diff --git a/pytest_testrail/model/plan.py b/pytest_testrail/model/plan.py new file mode 100644 index 0000000..0d264a8 --- /dev/null +++ b/pytest_testrail/model/plan.py @@ -0,0 +1,191 @@ +from datetime import datetime + +from pytest_testrail.helper import TestRailError +from pytest_testrail.model.run import Run + + +class Plan(object): + def __init__(self, content=None): + self._content = content or dict() + + def __str__(self): + return self.name + + @property + def assignedto_id(self): + return self._content.get('assignedto_id') + + @property + def blocked_count(self): + return self._content.get('blocked_count') + + @property + def completed_on(self): + try: + return datetime.fromtimestamp(int(self._content.get('completed_on'))) + except TypeError: + return None + + @property + def created_by(self): + return self._content.get('created_by') + + @property + def created_on(self): + try: + return datetime.fromtimestamp(int(self._content.get('created_on'))) + except TypeError: + return None + + @property + def custom_status_count(self): + return self._content.get('custom_status_count') + + @property + def description(self): + return self._content.get('description') + + @description.setter + def description(self, value): + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['description'] = value + + @property + def entries(self): + return list(map(Entry, self._content.get('entries'))) + + @property + def failed_count(self): + return self._content.get('failed_count') + + @property + def id(self): + return self._content.get('id') + + @property + def is_completed(self): + return self._content.get('is_completed') + + @property + def milestone_id(self): + return self._content.get('milestone_id') + + @milestone_id.setter + def milestone_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an int') + self._content['milestone_id'] = value + + @property + def name(self): + return self._content.get('name') + + @name.setter + def name(self, value): + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['name'] = value + + @property + def passed_count(self): + return self._content.get('passed_count') + + @property + def project_id(self): + return self._content.get('project_id') + + @property + def retest_count(self): + return self._content.get('retest_count') + + @property + def untested_count(self): + return self._content.get('untested_count') + + @property + def url(self): + return self._content.get('url') + + def raw_data(self): + return self._content + + +class Entry(object): + def __init__(self, content): + self._content = content + + @property + def assigned_to(self): + return self._content.get('assignedto_id') + + @property + def case_ids(self): + return self._content.get('case_ids') + + @case_ids.setter + def case_ids(self, value): + self._content['case_ids'] = value + + @property + def description(self): + return self._content.get('description') + + @description.setter + def description(self, value): + if value is not None: + if not isinstance(value, str): + raise TestRailError('input must be string or None') + self._content['description'] = value + + @property + def id(self): + return self._content.get('id') + + @property + def include_all(self): + return self._content.get('include_all') + + @include_all.setter + def include_all(self, value): + if not isinstance(value, bool): + raise TestRailError('include_all must be a boolean') + self._content['include_all'] = value + + @property + def name(self): + return self._content.get('name') + + @property + def runs(self): + return list(map(RunEntry, self._content.get('runs'))) + + @runs.setter + def runs(self, value): + self._content['runs'] = value + + @property + def suite_id(self): + return self._content.get('suite_id') + + @suite_id.setter + def suite_id(self, value): + if not isinstance(value, str): + raise TestRailError('input must be an int') + self._content['suite_id'] = value + + def raw_data(self): + return self._content + + +class RunEntry(Run): + def __init__(self, content): + super(RunEntry, self).__init__(content) + + @property + def entry_id(self): + return self._content.get('entry_id') + + @property + def entry_index(self): + return self._content.get('entry_index') diff --git a/pytest_testrail/model/priority.py b/pytest_testrail/model/priority.py new file mode 100644 index 0000000..7f307f5 --- /dev/null +++ b/pytest_testrail/model/priority.py @@ -0,0 +1,26 @@ +class Priority(object): + def __init__(self, content): + self._content = content + + def __str__(self): + return self.name + + @property + def id(self): + return self._content.get('id') + + @property + def is_default(self): + return bool(self._content.get('is_default')) + + @property + def level(self): + return self._content.get('priority') + + @property + def name(self): + return self._content.get('name') + + @property + def short_name(self): + return self._content.get('short_name') diff --git a/pytest_testrail/model/project.py b/pytest_testrail/model/project.py new file mode 100644 index 0000000..f5b7de0 --- /dev/null +++ b/pytest_testrail/model/project.py @@ -0,0 +1,91 @@ +from datetime import datetime + +from pytest_testrail.helper import TestRailError + + +class Project(object): + def __init__(self, response=None): + self._content = response or dict() + + def __str__(self): + return self.name + + @property + def announcement(self): + """The description/announcement of the project""" + return self._content.get('announcement') + + @announcement.setter + def announcement(self, msg): + if not isinstance(msg, str): + raise TestRailError('input must be a string') + self._content['announcement'] = msg + + @property + def completed_on(self): + """The date/time when the project was marked as completed""" + if self.is_completed: + return datetime.fromtimestamp(self._content.get('completed_on')) + return None + + @property + def id(self): + """The unique ID of the project""" + return self._content.get('id') + + @property + def is_completed(self): + """True if the project is marked as completed and false otherwise""" + return self._content.get('is_completed', False) + + @is_completed.setter + def is_completed(self, value): + if not isinstance(value, bool): + raise TestRailError('input must be a boolean') + self._content['is_completed'] = value + + @property + def name(self): + """The name of the project""" + return self._content.get('name') + + @name.setter + def name(self, value): + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['name'] = value + + @property + def show_announcement(self): + """True to show the announcement/description and false otherwise""" + return self._content.get('show_announcement', False) + + @show_announcement.setter + def show_announcement(self, value): + if not isinstance(value, bool): + raise TestRailError('input must be a boolean') + self._content['show_announcement'] = value + + @property + def suite_mode(self): + """The suite mode of the project (1 for single suite mode, + 2 for single suite + baselines, 3 for multiple suites) + (added with TestRail 4.0) + """ + return self._content.get('suite_mode') + + @suite_mode.setter + def suite_mode(self, mode): + if not isinstance(mode, int): + raise TestRailError('input must be an integer') + if mode not in [1, 2, 3]: + raise TestRailError('input must be a 1, 2, or 3') + self._content['suite_mode'] = mode + + @property + def url(self): + """The address/URL of the project in the user interface""" + return self._content.get('url') + + def raw_data(self): + return self._content diff --git a/pytest_testrail/model/result.py b/pytest_testrail/model/result.py new file mode 100644 index 0000000..843fa35 --- /dev/null +++ b/pytest_testrail/model/result.py @@ -0,0 +1,114 @@ +from datetime import datetime, timedelta + +from pytest_testrail.helper import custom_methods, TestRailError, testrail_duration_to_timedelta + + +class Result(object): + def __init__(self, content=None): + self._content = content or dict() + self._custom_methods = custom_methods(self._content) + + def __getattr__(self, attr): + if attr in self._custom_methods: + return self._content.get(self._custom_methods[attr]) + raise AttributeError('"{}" object has no attribute "{}"'.format( + self.__class__.__name__, attr)) + + @property + def assignedto_id(self): + return self._content.get('assignedto_id') + + @assignedto_id.setter + def assignedto_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be int') + self._content['assignedto_id'] = value + + @property + def comment(self): + return self._content.get('comment') + + @comment.setter + def comment(self, value): + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['comment'] = value + + @property + def created_by(self): + return self._content.get('created_by') + + @property + def created_on(self): + try: + return datetime.fromtimestamp(int(self._content.get('created_on'))) + except TypeError: + return None + + @property + def defects(self): + defects = self._content.get('defects') + return defects.split(',') if defects else list() + + @defects.setter + def defects(self, values): + if not isinstance(values, list): + raise TestRailError('input must be a list of strings') + if not all(map(lambda x,: isinstance(x, str), values)): + raise TestRailError('input must be a list of strings') + if len(values) > 0: + self._content['defects'] = ','.join(values) + else: + self._content['defects'] = None + + @property + def elapsed(self): + duration = self._content.get('elapsed') + if duration is None: + return None + return testrail_duration_to_timedelta(duration) + + @elapsed.setter + def elapsed(self, td): + if not isinstance(td, timedelta): + raise TestRailError('input must be a timedelta') + if td > timedelta(weeks=10): + raise TestRailError('maximum elapsed time is 10 weeks') + self._content['elapsed'] = td.seconds + + @property + def id(self): + return self._content.get('id') + + @property + def status_id(self): + return self._content.get('status_id') + + @status_id.setter + def status_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be int') + self._content['status_id'] = value + + @property + def test_id(self): + return self._content.get('test_id') + + @test_id.setter + def test_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be int') + self._content['test_id'] = value + + @property + def version(self): + return self._content.get('version') + + @version.setter + def version(self, ver): + if not isinstance(ver, str): + raise TestRailError('input must be a string') + self._content['version'] = ver + + def raw_data(self): + return self._content diff --git a/pytest_testrail/model/run.py b/pytest_testrail/model/run.py new file mode 100644 index 0000000..c914077 --- /dev/null +++ b/pytest_testrail/model/run.py @@ -0,0 +1,146 @@ +from datetime import datetime + +from pytest_testrail.helper import TestRailError + + +class Run(object): + def __init__(self, content=None): + self._content = content or dict() + + def __str__(self): + return self.name + + @property + def assignedto_id(self): + return self._content.get('assignedto_id') + + @assignedto_id.setter + def assignedto_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be int') + self._content['assignedto_id'] = value + + @property + def blocked_count(self): + return self._content.get('blocked_count') + + @property + def case_ids(self): + return self._content.get('case_ids') + + @case_ids.setter + def case_ids(self, value): + self._content['case_ids'] = value + + @property + def completed_on(self): + try: + return datetime.fromtimestamp(int(self._content.get('completed_on'))) + except TypeError: + return None + + @property + def config(self): + return self._content.get('config') + + @property + def config_ids(self): + return self._content.get('config_ids') + + @property + def created_by(self): + return self._content.get('created_by') + + @property + def created_on(self): + try: + return datetime.fromtimestamp(int(self._content.get('created_on'))) + except TypeError: + return None + + @property + def custom_status_count(self): + return self._content.get('custom_status_count') + + @property + def description(self): + return self._content.get('description') + + @description.setter + def description(self, value): + if not isinstance(value, str): + raise TestRailError('input must be string or None') + self._content['description'] = value + + @property + def failed_count(self): + return self._content.get('failed_count') + + @property + def id(self): + return self._content.get('id') + + @property + def include_all(self): + return self._content.get('include_all') + + @include_all.setter + def include_all(self, value): + if not isinstance(value, bool): + raise TestRailError('include_all must be a boolean') + self._content['include_all'] = value + + @property + def is_completed(self): + return self._content.get('is_completed') + + @property + def milestone(self): + return self._content.get('milestone_id') + + @property + def name(self): + return self._content.get('name') + + @name.setter + def name(self, value): + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['name'] = value + + @property + def passed_count(self): + return self._content.get('passed_count') + + @property + def plan_id(self): + return self._content.get('plan_id') + + @property + def project_id(self): + return self._content.get('project_id') + + @property + def retest_count(self): + return self._content.get('retest_count') + + @property + def suite_id(self): + return self._content.get('suite_id') + + @suite_id.setter + def suite_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an int') + self._content['suite_id'] = value + + @property + def untested_count(self): + return self._content.get('untested_count') + + @property + def url(self): + return self._content.get('url') + + def raw_data(self): + return self._content diff --git a/pytest_testrail/model/section.py b/pytest_testrail/model/section.py new file mode 100644 index 0000000..5c2fef3 --- /dev/null +++ b/pytest_testrail/model/section.py @@ -0,0 +1,64 @@ +from pytest_testrail.helper import TestRailError + + +class Section(object): + def __init__(self, content=None): + self._content = content or dict() + + def __str__(self): + return self.name + + @property + def depth(self): + return self._content.get('depth') + + @property + def description(self): + return self._content.get('description') + + @property + def display_order(self): + return self._content.get('display_order') + + @description.setter + def description(self, value): + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['description'] = value + + @property + def id(self): + return self._content.get('id') + + @property + def name(self): + return self._content.get('name') + + @name.setter + def name(self, value): + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['name'] = value + + @property + def suite_id(self): + return self._content.get('suite_id') + + @suite_id.setter + def suite_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an int') + self._content['suite_id'] = value + + @property + def parent_id(self): + return self._content.get('parent_id') + + @parent_id.setter + def parent_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an int') + self._content['parent_id'] = value + + def raw_data(self): + return self._content diff --git a/pytest_testrail/model/status.py b/pytest_testrail/model/status.py new file mode 100644 index 0000000..69470bd --- /dev/null +++ b/pytest_testrail/model/status.py @@ -0,0 +1,42 @@ +class Status(object): + def __init__(self, content): + self._content = content + + def __str__(self): + return self.name + + @property + def id(self): + return self._content.get('id') + + @property + def name(self): + return self._content.get('name') + + @property + def label(self): + return self._content.get('label') + + @property + def color_dark(self): + return self._content.get('color_dark') + + @property + def color_medium(self): + return self._content.get('color_medium') + + @property + def color_bright(self): + return self._content.get('color_bright') + + @property + def is_system(self): + return self._content.get('is_system') + + @property + def is_untested(self): + return self._content.get('is_untested') + + @property + def is_final(self): + return self._content.get('is_final') diff --git a/pytest_testrail/model/suite.py b/pytest_testrail/model/suite.py new file mode 100644 index 0000000..48ea938 --- /dev/null +++ b/pytest_testrail/model/suite.py @@ -0,0 +1,71 @@ +from datetime import datetime + +from pytest_testrail.helper import TestRailError + + +class Suite(object): + def __init__(self, content=None): + self._content = content or dict() + + def __str__(self): + return self.name + + @property + def completed_on(self): + try: + return datetime.fromtimestamp(int(self._content.get('completed_on'))) + except TypeError: + return None + + @property + def description(self): + return self._content.get('description') + + @description.setter + def description(self, value): + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['description'] = value + + @property + def id(self): + return self._content.get('id') + + @property + def is_baseline(self): + return self._content.get('is_baseline') + + @property + def is_completed(self): + return self._content.get('is_completed') + + @property + def is_master(self): + return self._content.get('is_master') + + @property + def name(self): + return self._content.get('name') + + @name.setter + def name(self, value): + if not isinstance(value, str): + raise TestRailError('input must be a string') + self._content['name'] = value + + @property + def project_id(self): + return self._content.get('project_id') + + @project_id.setter + def project_id(self, value): + if not isinstance(value, int): + raise TestRailError('input must be an int') + self._content['project_id'] = value + + @property + def url(self): + return self._content.get('url') + + def raw_data(self): + return self._content diff --git a/pytest_testrail/model/templates.py b/pytest_testrail/model/templates.py new file mode 100644 index 0000000..e8ae6f0 --- /dev/null +++ b/pytest_testrail/model/templates.py @@ -0,0 +1,18 @@ +class Template(object): + def __init__(self, content): + self._content = content + + def __str__(self): + return self.name + + @property + def id(self): + return self._content.get('id') + + @property + def is_default(self): + return self._content.get('is_default') + + @property + def name(self): + return self._content.get('name') diff --git a/pytest_testrail/model/test.py b/pytest_testrail/model/test.py new file mode 100644 index 0000000..d6d3175 --- /dev/null +++ b/pytest_testrail/model/test.py @@ -0,0 +1,65 @@ +from pytest_testrail.helper import custom_methods, testrail_duration_to_timedelta + + +class Test(object): + def __init__(self, content=None): + self._content = content or dict() + self.custom_methods = custom_methods(self._content) + + def __getattr__(self, attr): + if attr in self.custom_methods: + return self._content.get(self._custom_methods[attr]) + raise AttributeError('"{}" object has no attribute "{}"'.format( + self.__class__.__name__, attr)) + + def __str__(self): + return self.title + + @property + def assignedto_id(self): + return self._content.get('assignedto_id') + + @property + def case_id(self): + return self._content.get('case_id') + + @property + def estimate(self): + duration = self._content.get('estimate') + if duration is None: + return None + return testrail_duration_to_timedelta(duration) + + @property + def estimate_forecast(self): + duration = self._content.get('estimate_forecast') + if duration is None: + return None + return testrail_duration_to_timedelta(duration) + + @property + def id(self): + return self._content.get('id') + + @property + def milestone_id(self): + return self._content.get('milestone_id') + + @property + def refs(self): + return self._content.get('refs') + + @property + def run_id(self): + return self._content.get('run_id') + + @property + def status_id(self): + return self._content.get('status_id') + + @property + def title(self): + return self._content.get('title') + + def raw_data(self): + return self._content diff --git a/pytest_testrail/testrail_api.py b/pytest_testrail/testrail_api.py new file mode 100644 index 0000000..cc2e399 --- /dev/null +++ b/pytest_testrail/testrail_api.py @@ -0,0 +1,94 @@ +""" +Description +""" + +from . import _category +from ._session import Session + + +class TestRailAPI(Session): + + @property + def cases(self): + """http://docs.gurock.com/testrail-api2/reference-cases""" + return _category.Cases(self) + + @property + def case_fields(self): + """http://docs.gurock.com/testrail-api2/reference-cases-fields""" + return _category.CaseFields(self) + + @property + def case_types(self): + """http://docs.gurock.com/testrail-api2/reference-cases-types""" + return _category.CaseTypes(self) + + @property + def configurations(self): + """http://docs.gurock.com/testrail-api2/reference-configs""" + return _category.Configurations(self) + + @property + def milestones(self): + """http://docs.gurock.com/testrail-api2/reference-milestones""" + return _category.Milestones(self) + + @property + def plans(self): + """http://docs.gurock.com/testrail-api2/reference-plans""" + return _category.Plans(self) + + @property + def priorities(self): + """http://docs.gurock.com/testrail-api2/reference-priorities""" + return _category.Priorities(self) + + @property + def projects(self): + """http://docs.gurock.com/testrail-api2/reference-projects""" + return _category.Projects(self) + + @property + def results(self): + """http://docs.gurock.com/testrail-api2/reference-results""" + return _category.Results(self) + + @property + def result_fields(self): + """http://docs.gurock.com/testrail-api2/reference-results-fields""" + return _category.ResultFields(self) + + @property + def runs(self): + """http://docs.gurock.com/testrail-api2/reference-runs""" + return _category.Runs(self) + + @property + def sections(self): + """http://docs.gurock.com/testrail-api2/reference-runs""" + return _category.Sections(self) + + @property + def statuses(self): + """http://docs.gurock.com/testrail-api2/reference-sections""" + return _category.Statuses(self) + + @property + def suites(self): + """http://docs.gurock.com/testrail-api2/reference-suites""" + return _category.Suites(self) + + @property + def templates(self): + """http://docs.gurock.com/testrail-api2/reference-templates""" + return _category.Templates(self) + + @property + def tests(self): + """http://docs.gurock.com/testrail-api2/reference-tests""" + return _category.Tests(self) + + @property + def users(self): + """http://docs.gurock.com/testrail-api2/reference-users""" + return _category.Users(self) diff --git a/pytest_testrail/testrail_utils.py b/pytest_testrail/testrail_utils.py new file mode 100644 index 0000000..33025f6 --- /dev/null +++ b/pytest_testrail/testrail_utils.py @@ -0,0 +1,270 @@ +from __future__ import print_function + +import json +from typing import List + +from pytest_testrail.helper import TestRailError +from pytest_testrail.model.case import Case +from pytest_testrail.model.result import Result +from pytest_testrail.model.section import Section +from pytest_testrail.model.suite import Suite +from pytest_testrail.testrail_api import TestRailAPI + +SEPARATOR_CHAR = ' - ' + + +def export_tests(tr: TestRailAPI, project_id: int, project_name: str, feature): + # Get a reference to the current project and dependencies + tr_project = tr.projects.get_project(project_id=project_id) + print('Collected project %s from TestRail' % tr_project.name) + + feature_name_raw = feature['feature']['name'].strip() + feature_description = feature['feature']['description'].replace('\n ', '\n').strip() + tr_project_suite = get_project_suite(tr, tr_project.id, feature_name_raw.split(SEPARATOR_CHAR)[0]) + + print('Collecting Cases for suite %s from TestRail' % tr_project_suite.name) + tr_suite_cases = tr.cases.get_cases(project_id=tr_project.id, suite_id=tr_project_suite.id) + + raw_custom_preconds = [] + for scenario in feature['feature']['children']: + if scenario['keyword'] == 'Background': + print('Collecting Case preconditions') + raw_custom_preconds = list('**' + rs['keyword'] + ':** ' + rs['text'] for rs in scenario['steps']) + continue + else: + if any('component_test' in sc['name'] for sc in scenario['tags']): + suite_section = { + 'name': 'Default Test Cases', + 'description': 'Default Section', + 'display_order': 1 + } + else: + suite_section = { + 'name': feature_name_raw.split(SEPARATOR_CHAR)[1], + 'description': feature_description, + 'display_order': 2 + } + tr_suite_section = get_suite_section(tr, tr_project.id, tr_project_suite, suite_section) + + if 'examples' in scenario: + examples_raw = scenario['examples'][0] + + table_rows = [] + table_header = examples_raw['tableHeader']['cells'] + for i in range(examples_raw['tableBody'].__len__()): + table_row = examples_raw['tableBody'][i] + row = {} + for j in range(table_row['cells'].__len__()): + row.update({table_header[j]['value']: table_row['cells'][j]['value']}) + table_rows.append(row) + + for table_row in table_rows: + raw_custom_data_set = json.dumps(table_row, indent=4, ensure_ascii=False) + raw_case = build_case(tr=tr, project_id=tr_project.id, suite_id=tr_project_suite.id, + section_id=tr_suite_section.id, feature=feature, scenario=scenario, + raw_custom_preconds=raw_custom_preconds, raw_custom_data_set=raw_custom_data_set, + project_name=project_name) + export_case(tr, tr_suite_section.id, tr_suite_cases, raw_case) + else: + raw_case = build_case(tr=tr, project_id=tr_project.id, suite_id=tr_project_suite.id, + section_id=tr_suite_section.id, feature=feature, scenario=scenario, + raw_custom_preconds=raw_custom_preconds, raw_custom_data_set=None, + project_name=project_name) + export_case(tr, tr_suite_section.id, tr_suite_cases, raw_case) + + +def export_tests_results(tr: TestRailAPI, project_variables: dict, scenarios_run: list, env_name: str): + print('\nPublishing results') + tr_active_plans = tr.plans.get_plans(project_variables['id'], is_completed=0) + tr_plan = next((plan for plan in tr_active_plans if plan.name == project_variables['test_plan']), None) + if tr_plan is None: + raise TestRailError('No Test Plan set with name %s for Automation Testing' % project_variables['test_plan']) + tr_plan = tr.plans.get_plan(tr_plan.id) + tr_statuses = tr.statuses.get_statuses() + + plan_entry_names = [plan_entry.name for plan_entry in tr_plan.entries] + feature_names = scenarios_run.keys() + + if feature_names.__len__() > plan_entry_names.__len__() \ + or not set(feature_names).issubset(plan_entry_names): + print('Not all test results will be published. Missing Test Suites: %s' % list( + set(feature_names) - set(plan_entry_names))) + + for tr_plan_entry in tr_plan.entries: + for tr_run in tr_plan_entry.runs: + tr_results = [] + if tr_run.config == env_name and tr_run.name in scenarios_run: + for scenario_run in scenarios_run[tr_run.name]: + tr_tests = tr.tests.get_tests(tr_run.id) + tr_test = next((test for test in tr_tests if test.title == scenario_run.name + and (test.custom_methods['custom_data_set'] is None + or ('custom_data_set' in test.custom_methods + and json.loads( + test.custom_methods['custom_data_set']) == scenario_run.data_set))) + , None) + + if tr_test is None: + print('Result for test %s not published to TestRail' % scenario_run.name) + else: + custom_step_results = [] + custom_steps_separated = tr_test.custom_methods['custom_steps_separated'] + passed = True + for scenario_step, tr_case_step in zip(scenario_run.steps, custom_steps_separated): + status_type = 'blocked' if not passed \ + else 'passed' if not scenario_step.failed \ + else 'failed' if scenario_step.failed \ + else 'untested' + if status_type == 'failed': + passed = False + status_id = next((st.id for st in tr_statuses if st.name == status_type), None) + exception_message = '' if status_type != 'failed' or not hasattr(scenarios_run, + 'exception_message') else scenario_run.exception_message + custom_step_results.append({ + 'content': tr_case_step['content'], + 'expected': tr_case_step['expected'], + 'actual': exception_message, + 'status_id': status_id + }) + status_type = 'failed' if scenario_run.failed else 'passed' + tr_result = Result({ + 'test_id': tr_test.id, + 'status_id': next(st.id for st in tr_statuses if st.name == status_type), + 'comment': '', + 'custom_step_results': custom_step_results + }) + tr_results.append(tr_result) + + if tr_results.__len__() != 0: + tr.results.add_results(tr_run.id, tr_results) + print('\nResults published') + + +# pylint: disable=too-many-arguments +def build_case(tr: TestRailAPI, project_id: int, suite_id: int, section_id: int, feature, scenario, raw_custom_preconds, + raw_custom_data_set=None, project_name=None) -> Case: + # Setting Case references + feature_refs = [ft for ft in feature['feature']['tags'] if project_name + '-' in ft['name']] + scenario_refs = [sc for sc in scenario['tags'] if project_name + '-' in sc['name']] + raw_refs = ', '.join(tg['name'].replace('@', '') for tg in (feature_refs + scenario_refs)) + + # Setting Case tags + raw_custom_tags = [sc['name'] for sc in scenario['tags'] + if ('automated' not in sc['name'] + and 'manual' not in sc['name'])] + \ + [ft['name'] for ft in feature['feature']['tags'] + if ('automated' not in ft['name'] + and 'manual' not in ft['name'] + and 'nondestructive' not in ft['name'] + and project_name + '-' not in ft['name'])] + + # Setting Case priority + priority_name = 'Critical' if filter(lambda sc: 'smoke' in sc['name'], scenario['tags']) \ + else 'High' if filter(lambda sc: 'sanity' in sc['name'], scenario['tags']) \ + else 'Medium' if filter(lambda sc: 'regression' in sc['name'], scenario['tags']) \ + else 'Low' + raw_priority = next((pr.id for pr in tr.priorities.get_priorities() if pr.name == priority_name), None) + + # Setting Case type + raw_type = next((ct.id for ct in tr.case_types.get_case_types() if ct.name == 'Functional'), None) + + # Setting Case template + raw_template = next((ct.id for ct in tr.templates.get_templates(project_id) if ct.name == 'Test Case (Steps)'), + None) + + # Setting Case automation + raw_custom_automation_type = '2' if any('stencil-automated' in sc['name'] for sc in scenario['tags']) \ + else '1' if any('automated' in sc['name'] for sc in scenario['tags']) else '0' + + # Setting Case steps + raw_steps = [{'content': rs, 'expected': ''} for rs in raw_custom_preconds] + raw_steps.extend([ + { + 'content': '**' + rs['keyword'].strip() + ':** ' + rs['text'].strip() + add_data_table(rs), + 'expected': '' + } + for rs in scenario['steps']]) + raw_case = Case({ + 'estimate': '10m', + 'priority_id': raw_priority, + 'refs': raw_refs, + 'custom_tags': ', '.join(raw_custom_tags), + 'suite_id': suite_id, + 'section_id': section_id, + 'title': scenario['name'], + 'type_id': raw_type, + 'template_id': raw_template, + 'custom_automation_type': raw_custom_automation_type, + 'custom_data_set': raw_custom_data_set, + 'custom_preconds': '\n'.join(str(rp) for rp in raw_custom_preconds), + 'custom_steps_separated': raw_steps + }) + return raw_case + + +def add_data_table(scenario_step): + if 'argument' not in scenario_step: + return '' + data_table = '\n*Data Table*\n' + table_rows = [rsa for rsa in scenario_step['argument']['rows']] + for i, table_row in enumerate(table_rows): + for j, rowCell in enumerate(table_row['cells']): + data_table += ('|%s' % rowCell['value']) + data_table += '|\n' + return data_table + + +# pylint: disable=protected-access +def export_case(tr: TestRailAPI, section_id: int, tr_suite_cases: List[Case], raw_case: Case): + tr_suite_case = next((sc for sc in tr_suite_cases + if sc.title == raw_case.title + and sc._custom_methods['custom_data_set'] == raw_case._custom_methods['custom_data_set']) + , None) + if tr_suite_case: + print('Upgrading Case ', tr_suite_case.title) + tr.cases.update_case(case_id=tr_suite_case.id, case=raw_case) + else: + print('Creating Case ', raw_case.title) + tr.cases.add_case(section_id=section_id, case=raw_case) + + +def get_project_test_plan(tr, tr_plan_name, test_market): + test_plan_name = '%s_%s' % (tr_plan_name, test_market) + tr_plans = tr.plans() + tr_plan = next((tp for tp in tr_plans if tp.name == test_plan_name), None) + if tr_plan is None: + error_message = 'There is no Test Plan with name %s set on TestRail' % test_plan_name + raise TestRailError(error_message) + print('Collecting Test Plan ', tr_plan.name, ' from TestRail') + return tr_plan + + +def get_project_suite(tr: TestRailAPI, tr_project_id: int, feature_name) -> Suite: + tr_project_suites = tr.suites.get_suites(tr_project_id) + if any((tr_project_suite.name == feature_name) for tr_project_suite in tr_project_suites): + print('Collecting Suite ', feature_name, ' from TestRail') + return next((ps for ps in tr_project_suites if ps.name == feature_name)) + + print('No Suite with name ', feature_name, ' was found on TestRail') + new_project_suite = Suite({ + 'name': feature_name, + 'description': '', + 'is_baseline': False, + 'is_completed': False, + 'is_master': False, + 'project_id': tr_project_id + }) + print('Creating new Suite ', feature_name) + return tr.suites.add_suite(project_id=tr_project_id, suite=new_project_suite) + + +def get_suite_section(tr: TestRailAPI, project_id: int, tr_project_suite: Suite, suite_section) -> Section: + print('Collecting Sections for suite ', tr_project_suite.name, ' from TestRail') + tr_suite_sections = tr.sections.get_sections(project_id, tr_project_suite.id) + if not any(tr_suite_section.name == suite_section['name'] for tr_suite_section in tr_suite_sections): + suite_section['depth'] = 0 + suite_section['suite_id'] = tr_project_suite.id + print('No Section with name %s was found for suite %s. Creating new Section.' + % (suite_section['name'], tr_project_suite.name)) + tr_suite_sections.append(tr.sections.add_section(project_id=project_id, section=Section(suite_section))) + return next(tr_suite_section for tr_suite_section in tr_suite_sections + if tr_suite_section.name == suite_section['name'])