YouTrack
Powerful project management for all your teams
优化工作流 — 第 13 部分:支持测试管理方案
欢迎回到我们的“优化工作流”系列! 我们收到了一些来自社区的请求,用户们希望讨论可由 QA 团队使用的 YouTrack 功能。 现在,我们将介绍如何在 YouTrack 中维护测试管理方案 (TMS)。 本文将演示如何设置 YouTrack 以使您能够在 YouTrack 内部维护测试管理功能,从而避免使用第三方解决方案。 这种方式可帮助您节省许可证成本,并使测试管理流程更加顺畅。
本文适用于测试经理以及任何对 YouTrack 工作流感兴趣的人员。 文章为您提供了一个有关如何在 YouTrack 中构建测试流程的实用示例,其中包含针对项目和问题设置的建议,以及有关如何实现测试管理方案的说明等内容。
我们还精心编排了一系列可立即使用的工作流代码块,可用于自动化测试管理流程。 这些代码块可有效简化将测试用例与特定测试运行相关联、克隆测试运行、显示有关下次测试的系统建议等流程。
YouTrack 中针对测试管理项目的设置
您可能需要为测试管理项目使用特定于测试管理的问题类型,例如测试用例、测试套件(一组测试用例)、测试运行(一组为特定测试周期分配的测试用例或测试套件)以及分配给特定测试运行的测试用例执行。 您应配置 TMS 项目所需的问题类型。 以下 3 种问题链接类型可用于在您的测试管理任务之间建立连接和相关性:
- 标准“parent-subtasks”,可用于维护测试运行与测试用例执行以及测试套件与测试用例之间的关系。
- 自定义“Execution”,可用于维护测试用例与测试用例执行之间的关系。
- 自定义“Related Bug”,可用于维护失败的测试与分配的错误之间的关系。
测试要求可作为在文本字段中链接到问题的文章进行维护。
此外,对于您的测试管理任务,您可能需要设置自定义字段(例如 Test mode、Category、Test flow 和 Application),以及预定义值(例如预定义测试状态)。 需要专门显示一种问题类型的信息时,您也可以使用条件字段。

您可以根据您的业务需求调整“Test Case”和“Test Run”问题类型的一组预定义字段。
设置测试运行和测试执行
举例来说,当我们需要测试特定产品版本时,我们需要创建一个测试运行,并为其分配一定数量的相关测试套件和测试用例。 所需步骤如下:
- 创建问题类型为“Test Run”的问题。
- 使用 “Assigned test case and test suite” 自定义链接类型以关联到问题。
- 在弹出窗口中指定要在测试运行中包含的测试用例。
“Populate Test Run” 工作流的步骤将为您的 QA 团队节省时间和精力:
- 将在系统中创建所有选定测试套件和测试用例的副本。 所有问题都将具有“Test Case Execution”问题类型。
- “Execution”链接类型会将测试用例执行连接至选定的测试用例。
- 新创建的问题将链接至作为父问题的测试运行及其相关子任务。
工作方式如下:

展开代码块
var entities = require('@jetbrains/youtrack-scripting-api/entities');
var workflow = require('@jetbrains/youtrack-scripting-api/workflow');
exports.rule = entities.Issue.onChange({
title: 'Populate-test-run',
guard: function(ctx) {
var issue = ctx.issue;
return !issue.isChanged('project') && issue.Type && (issue.Type.name == ctx.Type.TestRun.name) && issue.links[ctx.Execution.outward].added.isNotEmpty() && issue.isReported;
},
action: function(ctx) {
var issue = ctx.issue;
var totalTestRuns = issue.links[ctx.Execution.outward].added.size;
issue.links[ctx.Execution.outward].added.forEach(function(TestCase) {
TestCase.links[ctx.Execution.inward].delete(issue);
var TestCaseRun = TestCase.copy();
TestCaseRun.Type = ctx.Type.TestExecution.name;
TestCaseRun.Status = ctx.Status.NoRun.name;
Object.keys(TestCaseRun.links).forEach(function(linkType) {
if (!TestCaseRun.links[linkType])
return;
TestCaseRun.links[linkType].clear();
});
TestCaseRun.summary = "[TEST_CASE_EXECUTION" + "] [" + TestCaseRun.summary + "]";
TestCaseRun.links[ctx.Subtask.inward].add(issue);
issue.links[ctx.Subtask.outward].add(TestCaseRun);
TestCaseRun.links[ctx.Execution.outward].add(TestCase);
});
issue.fields['Total number of test cases'] = totalTestRuns;
},
requirements: {
Execution: {
type: entities.IssueLinkPrototype,
name: 'Execution',
inward: 'Execution',
outward: 'Assigned test case or test suite'
},
Subtask: {
type: entities.IssueLinkPrototype,
name: 'Subtask',
inward: 'parent for',
outward: 'subtask of'
},
Type: {
type: entities.EnumField.fieldType,
TestExecution: {
name: "Test Case Execution"
},
TestRun: {
name: "Test Run"
},
TestCase: {
name: "Test Case"
},
TestSuite: {
name: "Test Suite"
}
},
Total: {
type: entities.Field.integerType,
name: 'Total number of test cases'
},
TotalFailed: {
type: entities.Field.integerType,
name: 'Number of failed test cases'
},
TotalPassed: {
type: entities.Field.integerType,
name: 'Number of passed test cases'
},
Status: {
type: entities.EnumField.fieldType,
InProgress: {
name: 'In Progress'
},
Passed: {
name: 'Passed'
},
Failed: {
name: 'Failed'
},
NoRun: {
name: 'No Run'
},
},
}
});
维护测试活动
测试运行设置全部完成后,测试工程师即可开始测试。 默认情况下,所有“Test Case Execution”和“Test Run”类型的问题均处于“No Run”状态。
如何在测试之间进行切换
使用一组测试时,您可以通过两种方式从一项测试切换到另一项测试:
- 在问题列表页面上手动更改测试状态。
- 打开一项测试,然后切换到下一项未完成的测试。 当您希望以这种方式切换测试时,将出现一个有用的弹出窗口,建议运行下一个测试。
可以自动执行以下操作:可使用以下代码实现上述操作。
展开代码块
action: function(ctx) { var issue = ctx.issue; if (!issue.links['subtask of'].isEmpty) { var parent = issue.links['subtask of'].first(); var TestRunList = parent.links[ctx.Subtask.outward]; var resultSet = null; var isPassing = true; TestRunList.forEach(function(v) { if (v.Status.name == ctx.Status.Failed.name) { isPassing = false; } else if ((v.Status.name == ctx.Status.InProgress.name) && (v.id !== issue.id)) { resultSet = v; } }); if (resultSet) { var otherIssueLink = '<a href="' + resultSet.url + '"> ' + resultSet.id + '</a>'; var message = 'Switch to next open test in current Test Run' + otherIssueLink + '.'; workflow.message(message); } } }
测试状态
从一项测试切换到另一项测试时,测试工程师可以指示测试“Passed”,也可以将测试状态更改为“Failed”(如果在执行测试用例期间识别到错误)。 基于我们最初为此项目配置的 YouTrack 设置,有多种预定义测试运行状态:
- Failing:至少有一项关联测试的状态为“No Run”,并且至少有一项关联测试的状态为“Failed”。
- Passing:至少有一项关联测试的状态为“No Run”,并且没有状态为“Failed”的关联测试。
- Failed:没有状态为“No Run”的关联测试,并且至少有一项关联测试的状态为“Failed”。
- Passed:没有状态为“No Run”或“Failed”的关联测试。

上面列出的状态可以帮助您的 QA 团队在整个测试周期内监控测试进度。 需要注意的是,测试运行状态仅取决于关联测试,不应手动更改。 需要使用按问题类型的状态机工作流来实现确定测试运行状态的过程。 工作流中也包含了测试开关代码块。
展开代码块
var entities = require('@jetbrains/youtrack-scripting-api/entities');
var workflow = require('@jetbrains/youtrack-scripting-api/workflow');
exports.rule = entities.Issue.stateMachine({
title: 'status-management',
stateFieldName: 'Status',
typeFieldName: 'Type',
defaultMachine: {
'No Run': {
initial: true,
transitions: {
'Failed': {
targetState: 'Failed',
action: function(ctx) {
var issue = ctx.issue;
if (!issue.links['subtask of'].isEmpty) {
var parent = issue.links['subtask of'].first();
var TestRunList = parent.links[ctx.Subtask.outward];
var resultSet = null;
var isPassing = true;
TestRunList.forEach(function(v) {
if (v.Status.name == ctx.Status.Failed.name) {
isPassing = false;
} else if ((v.Status.name == ctx.Status.InProgress.name) && (v.id !== issue.id)) {
resultSet = v;
}
});
if (resultSet) {
var otherIssueLink = '<a href="' + resultSet.url + '"> ' + resultSet.id + '</a>';
var message = 'Switch to next open test in current Test Run' + otherIssueLink + '.';
workflow.message(message);
// Updating Test Run Status
parent.fields["Status"] = ctx.Status.Failing;
} else {
parent.fields["Status"] = ctx.Status.Failed;
}
}
}
},
'Passed': {
guard: function(ctx) {
var issue = ctx.issue;
return !issue.isChanged('project') && !issue.becomesReported && issue.isReported && (issue.Type.name == ctx.Type.TestExecution.name);
},
targetState: 'Passed',
action: function(ctx) {
var issue = ctx.issue;
if (!issue.links['subtask of'].isEmpty) {
var parent = issue.links['subtask of'].first();
var TestRunList = parent.links[ctx.Subtask.outward];
var resultSet = null;
var isPassing = true;
TestRunList.forEach(function(v) {
if (v.Status.name == ctx.Status.Failed.name) {
isPassing = false;
} else if ((v.Status.name == ctx.Status.InProgress.name) && (v.id !== issue.id)) {
resultSet = v;
}
});
if (resultSet) {
var otherIssueLink = '<a href="' + resultSet.url + '"> ' + resultSet.id + '</a>';
var message = 'Switch to next open test in current Test Run' + otherIssueLink + '.';
workflow.message(message);
parent.fields["Status"] = (isPassing) ? ctx.Status.Passing : ctx.Status.Failing;
} else {
parent.fields["Status"] = (isPassing) ? ctx.Status.Passed : ctx.Status.Failed;
}
}
}
}
}
},
Passed: {
transitions: {
'Failed': {
guard: function(ctx) {
var issue = ctx.issue;
return !issue.isChanged('project') && !issue.becomesReported && issue.isReported && (issue.Type.name == ctx.Type.TestExecution.name);
},
targetState: 'Failed',
action: function(ctx) {
var issue = ctx.issue;
if (!issue.links['subtask of'].isEmpty) {
var parent = issue.links['subtask of'].first();
var TestRunList = parent.links[ctx.Subtask.outward];
var resultSet = null;
TestRunList.forEach(function(v) {
if (v.Status.name == ctx.Status.Failed.name) {
} else if ((v.Status.name == ctx.Status.InProgress.name) && (v.id !== issue.id)) {
resultSet = v;
}
});
if (resultSet) {
var otherIssueLink = '<a href="' + resultSet.url + '"> ' + resultSet.id + '</a>';
var message = 'Switch to next open test in current Test Run' + otherIssueLink + '.';
workflow.message(message);
parent.fields["Status"] = ctx.Status.Failing;
} else {
parent.Status = ctx.Status.Failed;
}
}
}
},
'No Run': {
guard: function(ctx) {
var issue = ctx.issue;
return !issue.isChanged('project') && !issue.becomesReported && issue.isReported && (issue.Type.name == ctx.Type.TestExecution.name);
},
targetState: 'No Run',
action: function(ctx) {
var issue = ctx.issue;
if (!issue.links['subtask of'].isEmpty) {
var parent = issue.links['subtask of'].first();
var TestRunList = parent.links[ctx.Subtask.outward];
var ActiveTestRun = false;
var isPassing = true;
TestRunList.forEach(function(v) {
if (v.Status.name == ctx.Status.Failed.name) {
isPassing = false;
ActiveTestRun = true;
} else if ((v.Status.name == ctx.Status.Passed.name) && (v.id !== issue.id)) {
ActiveTestRun = true;
}
});
if (ActiveTestRun) {
parent.fields["Status"] = (isPassing) ? ctx.Status.Passing : ctx.Status.Failing;
} else parent.fields["Status"] = ctx.Status.InProgress;
}
}
}
}
},
Failed: {
transitions: {
'Passed': {
guard: function(ctx) {
var issue = ctx.issue;
return !issue.isChanged('project') && !issue.becomesReported && issue.isReported && (issue.Type.name == ctx.Type.TestExecution.name);
},
targetState: 'Passed',
action: function(ctx) {
var issue = ctx.issue;
if (!issue.links['subtask of'].isEmpty) {
var parent = issue.links['subtask of'].first();
var TestRunList = parent.links[ctx.Subtask.outward];
var resultSet = null;
var isPassing = true;
TestRunList.forEach(function(v) {
if ((v.Status.name == ctx.Status.Failed.name) && (v.id !== issue.id)) {
isPassing = false;
} else if ((v.Status.name == ctx.Status.InProgress.name) && (v.id !== issue.id)) {
resultSet = v;
}
});
if (resultSet) {
var otherIssueLink = '<a href="' + resultSet.url + '"> ' + resultSet.id + '</a>';
var message = 'Switch to next open test in current Test Run' + otherIssueLink + '.';
workflow.message(message);
parent.fields["Status"] = (isPassing) ? ctx.Status.Passing : ctx.Status.Failing;
} else {
parent.fields["Status"] = (isPassing) ? ctx.Status.Passed : ctx.Status.Failed;
}
}
}
},
'No Run': {
guard: function(ctx) {
var issue = ctx.issue;
return !issue.isChanged('project') && !issue.becomesReported && issue.isReported && (issue.Type.name == ctx.Type.TestExecution.name);
},
targetState: 'No Run',
action: function(ctx) {
var issue = ctx.issue;
if (!issue.links['subtask of'].isEmpty) {
var parent = issue.links['subtask of'].first();
var TestRunList = parent.links[ctx.Subtask.outward];
var ActiveTestRun = false;
var isPassing = true;
TestRunList.forEach(function(v) {
if ((v.Status.name == ctx.Status.Failed.name) && (v.id !== issue.id)) {
isPassing = false;
ActiveTestRun = true;
} else if ((v.Status.name == ctx.Status.Passed.name) && (v.id !== issue.id)) {
ActiveTestRun = true;
}
});
if (ActiveTestRun) {
parent.fields["Status"] = (isPassing) ? ctx.Status.Passing : ctx.Status.Failing;
} else parent.fields["Status"] = ctx.Status.InProgress;
}
}
}
}
}
},
alternativeMachines: {
'Test Run': {
'No Run': {
initial: true,
transitions: {
'Failing': {
targetState: 'Failing',
action: function(ctx) {
/* Add actions. */
}
},
'Failed': {
targetState: 'Failed',
action: function(ctx) {
/* Add actions. */
}
},
'Passing': {
targetState: 'Passing',
action: function(ctx) {
/* Add actions. */
}
},
'Passed': {
targetState: 'Passed',
action: function(ctx) {
/* Add actions. */
}
}
}
},
Failing: {
transitions: {
'Passing': {
targetState: 'Passing',
action: function(ctx) {
/* Add actions . */
}
},
'Passed': {
targetState: 'Passed',
action: function(ctx) {
/* Add actions. */
}
},
'Failed': {
targetState: 'Failed',
action: function(ctx) {
/* Add actions. */
}
}
}
},
Passing: {
transitions: {
'Failing': {
targetState: 'Passing',
action: function(ctx) {
workflow.check(false, workflow.i18n('Test Run has-read-only status which is defined based on assigned tests statuses'));
}
},
'Passed': {
targetState: 'Passed',
action: function(ctx) {
/* Add actions. */
}
},
'Failed': {
targetState: 'Failed',
action: function(ctx) {
/* Add actions. */
}
}
}
},
Failed: {
transitions: {
'Passing': {
targetState: 'Passing',
action: function(ctx) {
/* Add actions. */
}
},
'Passed': {
targetState: 'Passed',
action: function(ctx) {
/* Add actions. */
}
},
'Failing': {
targetState: 'Failed',
action: function(ctx) {
/* Add actions. */
}
}
}
},
Passed: {
transitions: {
'Passing': {
targetState: 'Passing',
action: function(ctx) {
/* Add actions. */
}
},
'Failed': {
targetState: 'Passed',
action: function(ctx) {
/* Add actions. */
},
},
'Failing': {
targetState: 'Failed',
action: function(ctx) {
/* Add actions. */
}
}
}
}
}
},
requirements: {
Assignee: {
type: entities.User.fieldType
},
Status: {
type: entities.EnumField.fieldType,
InProgress: {
name: 'No Run'
},
Failing: {
name: 'Failing'
},
Passing: {
name: 'Passing'
},
Passed: {
name: 'Passed'
},
Failed: {
name: 'Failed'
},
},
Type: {
type: entities.EnumField.fieldType,
TestRun: {
name: "Test Run"
},
TestExecution: {
name: "Test Case Execution"
}
},
Subtask: {
type: entities.IssueLinkPrototype,
name: 'Subtask',
inward: 'subtask of',
outward: 'parent for'
},
}
});
测试统计信息
您可能也有兴趣了解关键测试周期统计信息。 出于演示目的,我们包含了以下指标:
- 分配给特定测试运行的测试总数。
- 状态为“Passed”的测试的数量。
- 状态为“Failed”的测试的数量。

基于下方代码,在发生以下任何变更时,所有指标都将更新:测试状态变更、对测试运行分配新测试以及从测试运行移除测试。
展开代码块
exports.calculateStatuses = function(parent) {
var totalTR = 0;
var totalFailed = 0;
var totalPassed = 0;
if (!parent.links['parent for']) {
return;
} else {
parent.links['parent for'].forEach(function(tr) {
totalTR++;
if (tr.Status.name == 'Passed') {
totalFailed++;
}
if (tr.Status.name == 'Failed') {
totalPassed++;
}
});
parent.fields['Total number of test cases'] = totalTR;
parent.fields['Number of passed test cases'] = totalPassed;
parent.fields['Number of failed test cases'] = totalFailed;
return true;
}
};
exports.resetStatuses = function(testRun, testRunCopy) {
testRunCopy.fields['Total number of test cases'] = testRun.fields['Total number of test cases'];
testRunCopy.fields['Number of passed test cases'] = 0;
testRunCopy.fields['Number of failed test cases'] = 0;
return true;
};
在我们的用例中,此代码在多个工作流规则下会被触发,例如“Update stats when links are adjusted”、“Switch to the next test case”等。 如果要采用一组能够满足您特定需求的指标,您可以添加所需的自定义字段并调整您的工作流逻辑。
问题跟踪器功能
您可能需要创建单独的任务来解决针对失败的测试发现的错误,并将其链接到标识相关测试用例执行的问题。 您可以使用自定义问题链接类型将失败的测试链接至其相关的错误,或者使用包含对该错误的引用的自定义文本字段。 组织一个单独的 YouTrack 项目来跟踪错误可能更为方便。

可预见的功能增强
您可以扩展 YouTrack 的功能以满足其他业务需求。 以下提供了多种可供考虑的选项。
克隆现有的测试运行
有时,进行与现有测试相似但包含一些细微变更(例如,变更分配的版本)的新测试运行可能会非常有帮助。 “Create Test Run copy”菜单项专为此类情况而创建。 此菜单项仅适用于问题类型为“Test Run”的问题,可触发以下操作:
- 创建测试运行的副本以及为其分配的测试用例执行的副本。
- 所有新创建的测试用例的执行副本都使用“父-子”问题链接类型分配给新测试运行。 此外,这些副本还将使用“Execution”问题链接类型分配给原始测试用例(以保持可追溯性)。
- 对于新创建的问题,将丢弃所有状态和统计信息。

以上逻辑使用“Create Test Run copy”操作规则实现。
展开代码块
var workflow = require('@jetbrains/youtrack-scripting-api/workflow');
var entities = require('@jetbrains/youtrack-scripting-api/entities');
var utils = require('../calculate-tms-stats/utils');
exports.rule = entities.Issue.action({
title: 'Create Test Run copy',
command: 'Test Run Creation',
guard: function(ctx) {
return ctx.issue.isReported && (ctx.issue.Type.name == ctx.Type.TestRun.name);
},
action: function(ctx) {
var issue = ctx.issue;
var TestRunCopy = issue.copy(issue.project);
TestRunCopy.Status = ctx.Status.InProgress;
var oldTestList = issue.links[ctx.Subtask.outward];
oldTestList.forEach(function(v) {
var newTest = v.copy(v.project);
newTest.Status = ctx.Status.InProgress;
newTest.links[ctx.Subtask.inward].delete(issue);
newTest.links[ctx.Subtask.inward].add(TestRunCopy);
});
utils.resetStatuses(issue, TestRunCopy);
var newTestRunLink = '<a href="' + TestRunCopy.url + '"> ' + TestRunCopy.id + '</a>';
var message = 'New Test Run has been created ' + newTestRunLink + '.';
workflow.message(message);
},
requirements: {
Execution: {
type: entities.IssueLinkPrototype,
name: 'Execution',
inward: 'Execution',
outward: 'Assigned Test case or test suite'
},
Subtask: {
type: entities.IssueLinkPrototype,
name: 'Subtask',
inward: 'subtask of',
outward: 'parent for'
},
Type: {
type: entities.EnumField.fieldType,
TestRun: {
name: "Test Run"
},
},
Status: {
type: entities.EnumField.fieldType,
InProgress: {
name: 'No Run'
},
}
}
});
限制用户的操作
为了避免在使用测试方案时发生潜在错误(例如使用错误的问题类型),您可以限制用户可能采取的操作。 例如,您可以实现操作以确保用户将测试用例链接到测试运行。当用户将测试运行链接到一个问题时,该操作将检查链接的问题类型是 “Test Case” 还是 “Test Suite”。如果都不是,该操作将发送警告,阻止用户继续处理。

可以将以下代码块添加到“populate test run”工作流。
展开代码块
var message = '<a href="' + TestCase.url + '"> ' + TestCase.id + '</a>';
workflow.check((TestCase.Type.name == ctx.Type.TestCase.name) || (TestCase.Type.name == ctx.Type.TestSuite.name), workflow.i18n('\'Test Run\' can be linked to \'Test Case\' and \'Test Suite\' only, but {0} has \'{1}\' type!', message, TestCase.Type.name));
如 上文所述,测试运行状态应为只读,因为它取决于测试执行的进度。 只需包含一个代码块即可直接实现这一设置,该代码块可限制手动变更问题类型为“Test Run”的问题的状态。

例如,在“status-management”工作流中,“Test Run”问题类型的“transitions”部分可以进行以下调整:
展开代码块
'Failing': {
targetState: 'Failing',
action: function(ctx) {
workflow.check(false, workflow.i18n('Test run has read-only status which is defined based on assigned tests statuses'));
}
},
...
}
报告和可追溯性
报告
最后,当测试周期结束时,您可能需要对结果进行分析。 YouTrack 报告功能可用于此目的。您可以选择报告类型,它将显示与测试管理过程相关的关键信息。 出于演示目的,我们将向仪表板添加 2 个报告:
-
按“version”字段筛选的累积流报告。此类状态报告涉及到特定的产品版本。 报告中显示了关键指标(例如状态为“Passed”、“Failed”和“No Run”的测试的数量),并在时间线上呈现。 要显示跨版本数据,您应该为每个版本创建一个报告。 建议将所有报告添加到仪表板中,这种方式可以将所有数据集中到一处,便于使用。
报告设置:
报告结果:
- 问题分布报告。 该报告是一张快照,其中包含多个产品版本的关键指标(例如,每种状态的测试运行的数量)。 我们专门针对此演示提供了报告结果,可帮助您对比各个版本并分析版本稳定性。
报告设置:
报告结果:
可追溯性
YouTrack 会针对每个“Test Execution”类型的问题存储与测试周期相关的关键信息,因此您始终可以访问测试历史记录并识别任何相关错误。
如果您有一个测试用例,则可以引用任何涉及该测试用例的测试执行。

对于测试用例执行,您可以引用测试运行。

针对失败的测试用例执行,您始终可以引用相关 Bug 列表。

要详细了解本文所述工作流或讨论与工作流相关的其他主题,请随时加入我们在 Slack 中的 YouTrack 工作流社区。





