Test Plans

Parameterized Tests and Shared Steps

A comprehensive guide to parameterized tests and shared steps in Azure DevOps Test Plans, covering parameter tables, data-driven iteration, shared step creation and management, nested shared steps, REST API automation for bulk operations, and strategies for reducing test case maintenance overhead.

Parameterized Tests and Shared Steps

Overview

Two features in Azure DevOps Test Plans eliminate most test case duplication: parameterized tests and shared steps. Parameterized tests let you define a test case once and run it with multiple data sets -- different user roles, input values, browser configurations, or any variable that changes the test context without changing the procedure. Shared steps extract common sequences (login, navigation, data setup) into reusable blocks that you maintain in one place and reference from dozens of test cases. Together, they reduce a 200-test-case suite to 40-50 unique procedures with parameter variations and shared building blocks.

I have seen teams maintain hundreds of nearly identical test cases that differ only in the input data or the first three steps. When the login flow changes, someone has to update 150 test cases manually -- and they miss a few every time. Parameterized tests and shared steps solve this structurally, not through discipline. When the login flow changes, you update one shared step work item and every test case that references it is immediately current. When you need to test with a new user role, you add a row to a parameter table instead of duplicating an entire test case.

Prerequisites

  • An Azure DevOps organization with Azure Test Plans enabled
  • Basic + Test Plans access level
  • At least one test plan with test suites created
  • Familiarity with test case work items and the test runner
  • Node.js 18+ for the automation scripts
  • A Personal Access Token with Work Items and Test Management scope

Understanding Parameterized Tests

How Parameters Work

A parameterized test case uses @parameterName placeholders in step actions and expected results. Azure DevOps replaces these placeholders with values from a parameter table when the test is executed.

Each row in the parameter table creates a separate iteration. When a tester runs a parameterized test case, the test runner cycles through each iteration, substituting the parameter values into the steps. Each iteration gets its own pass/fail result.

Creating Parameterized Test Cases

In the test case work item editor, write steps using @ prefixed parameter names:

Test Case: Verify user login with different roles

Step 1:
  Action: Navigate to the login page
  Expected: Login page displays

Step 2:
  Action: Enter @username in the email field
  Expected: Email field accepts input

Step 3:
  Action: Enter @password in the password field
  Expected: Password field shows masked characters

Step 4:
  Action: Click "Sign In"
  Expected: @expectedResult

Step 5:
  Action: Verify the navigation bar shows the correct role indicator
  Expected: Role badge displays "@expectedRole"

Then define the parameter table in the Parameters tab:

@username @password @expectedResult @expectedRole
[email protected] AdminP@ss1 Dashboard with admin panel visible Administrator
[email protected] MgrP@ss1 Dashboard with team management visible Manager
[email protected] ViewP@ss1 Dashboard with read-only indicators Viewer
[email protected] DisP@ss1 Error: Account disabled N/A
[email protected] LockP@ss1 Error: Account locked after 5 attempts N/A

This single test case produces 5 iterations. One test case replaces 5 separate test cases that would otherwise contain identical steps with different data.

Parameter Naming Conventions

Use descriptive parameter names that make the test steps readable:

  • Good: @username, @expectedErrorMessage, @productName, @quantity
  • Bad: @param1, @data, @x, @input

When someone reads the step "Enter @username in the email field," it is immediately clear what value goes there. "Enter @param1 in the email field" requires checking the parameter table to understand the test.

Shared Parameters

Azure DevOps supports Shared Parameter Sets -- parameter tables that can be reused across multiple test cases. This is useful when the same data set applies to multiple test procedures.

For example, a "User Credentials" shared parameter set containing test account data can be referenced by the login test case, the password reset test case, the profile update test case, and any other test that needs to authenticate.

To create a shared parameter set:

  1. Open the test case work item
  2. Go to the Parameters tab
  3. Click "Add shared parameters"
  4. Create a new shared parameter set with a name (e.g., "Standard Test Users")
  5. Define the columns and rows

Any test case that references this shared parameter set automatically gets all its iterations. When you add a new row to the shared parameter set, every referencing test case gains a new iteration.

When to Use Parameters vs Separate Test Cases

Use parameterized tests when:

  • The test procedure is identical but the data changes
  • You are testing boundary values (min, max, zero, negative)
  • You are testing multiple user roles through the same workflow
  • You are validating form input with valid and invalid data

Use separate test cases when:

  • The procedure differs significantly between scenarios
  • Different steps are needed for different inputs
  • The expected behavior changes the workflow path, not just the result

A parameterized test should not have steps like "If @role is Admin, click Settings." That branching logic means you need separate test cases.

Understanding Shared Steps

How Shared Steps Work

A shared step is a work item type in Azure DevOps that contains a sequence of test steps. When inserted into a test case, the shared steps appear as a collapsible block. During test execution, the test runner expands the shared steps and the tester executes them as part of the test case.

The key property of shared steps is single-source maintenance. When you update the shared step work item, every test case that references it automatically reflects the change. No propagation delay, no manual updates, no missed test cases.

Creating Shared Steps

There are two ways to create shared steps:

Method 1: Extract from an existing test case

  1. Open a test case that contains the steps you want to share
  2. Select the steps (hold Shift or Ctrl to multi-select)
  3. Click "Create shared steps" from the toolbar
  4. Name the shared steps work item
  5. The selected steps are replaced with a reference to the new shared steps

Method 2: Create directly

  1. Create a new work item of type "Shared Steps"
  2. Add the step actions and expected results
  3. Save the work item
  4. Insert it into test cases using the "Insert shared steps" button

Common Shared Step Patterns

Authentication flows:

Shared Steps: "Login as Admin User"
  Step 1: Navigate to https://app.example.com/login
           Expected: Login page loads
  Step 2: Enter "[email protected]" in the email field
           Expected: Email accepted
  Step 3: Enter "AdminP@ss123" in the password field
           Expected: Password masked
  Step 4: Click "Sign In"
           Expected: Dashboard loads, "Welcome, Admin" displayed

Navigation sequences:

Shared Steps: "Navigate to Project Settings"
  Step 1: Click the gear icon in the sidebar
           Expected: Settings menu expands
  Step 2: Click "Project Settings"
           Expected: Project Settings page loads
  Step 3: Verify the settings navigation panel is visible
           Expected: All settings categories displayed

Data setup procedures:

Shared Steps: "Create Test Work Item"
  Step 1: Click "New Work Item" > "Task"
           Expected: New task form opens
  Step 2: Enter "Test Task - [timestamp]" as the title
           Expected: Title field accepts input
  Step 3: Set Priority to 2
           Expected: Priority dropdown shows "2"
  Step 4: Click "Save"
           Expected: Task is created with an ID number displayed

Cleanup procedures:

Shared Steps: "Delete Test Data"
  Step 1: Navigate to the test item created during this test
           Expected: Item detail page loads
  Step 2: Click "Delete" from the actions menu
           Expected: Confirmation dialog appears
  Step 3: Click "Confirm Delete"
           Expected: Item is deleted, redirect to list view

Shared Steps with Parameters

Shared steps can use parameters, and those parameters can be mapped to the parent test case's parameters. This creates powerful reusable blocks:

Shared Steps: "Login as @role"
  Step 1: Navigate to the login page
           Expected: Login page loads
  Step 2: Enter @username in the email field
           Expected: Email accepted
  Step 3: Enter @password in the password field
           Expected: Password masked
  Step 4: Click Sign In
           Expected: Dashboard loads for @role

When this shared step is inserted into a test case, the @username, @password, and @role parameters link to the parent test case's parameter table. Each iteration of the parent test case passes its parameter values to the shared step.

Nesting Shared Steps

Shared steps can reference other shared steps, creating a hierarchy:

Shared Steps: "Complete Purchase Flow"
  [Shared Steps: "Login as Standard User"]   ← nested reference
  Step 1: Navigate to the product catalog
           Expected: Catalog page loads
  Step 2: Add "Test Product" to cart
           Expected: Cart count increases by 1
  [Shared Steps: "Checkout with Credit Card"]  ← nested reference
  Step 3: Verify order confirmation
           Expected: Order number displayed

Nesting is useful but keep the depth to 2 levels maximum. Deeply nested shared steps become difficult to debug when a step fails because the tester has to trace through multiple levels of indirection.

Managing Shared Steps at Scale

As your shared steps library grows, organize it:

  • Tag shared steps with area names: login, navigation, data-setup, cleanup
  • Name consistently: Use the pattern "Action - Context" (e.g., "Login as Admin", "Navigate to Settings", "Create Test Project")
  • Track usage: Query which test cases reference each shared step to understand impact before editing
  • Review quarterly: Remove unused shared steps and update stale ones

Complete Working Example

A Node.js script that manages parameterized test cases and shared steps via the Azure DevOps REST API, including bulk creation, parameter management, and shared step insertion.

var https = require("https");
var url = require("url");

var ORG = "my-organization";
var PROJECT = "my-project";
var PAT = process.env.AZURE_DEVOPS_PAT;
var API_VERSION = "7.1";

var BASE_URL = "https://dev.azure.com/" + ORG + "/" + PROJECT;
var AUTH = "Basic " + Buffer.from(":" + PAT).toString("base64");

function makeRequest(method, path, body, contentType) {
  return new Promise(function (resolve, reject) {
    var fullUrl = path.indexOf("https://") === 0 ? path : BASE_URL + path;
    var parsed = url.parse(fullUrl);
    var options = {
      hostname: parsed.hostname,
      path: parsed.path,
      method: method,
      headers: {
        "Content-Type": contentType || "application/json-patch+json",
        Authorization: AUTH,
      },
    };

    var req = https.request(options, function (res) {
      var data = "";
      res.on("data", function (chunk) {
        data += chunk;
      });
      res.on("end", function () {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(data ? JSON.parse(data) : null);
        } else {
          reject(new Error(method + " " + path + ": " + res.statusCode + " " + data));
        }
      });
    });

    req.on("error", reject);
    if (body) {
      req.write(JSON.stringify(body));
    }
    req.end();
  });
}

function escapeXml(str) {
  return String(str)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

function buildStepsXml(steps, sharedStepRefs) {
  var stepId = 0;
  var totalSteps = steps.length + (sharedStepRefs ? sharedStepRefs.length : 0);
  var xml = '<steps id="0" last="' + totalSteps + '">';

  // Insert shared step references first
  if (sharedStepRefs) {
    sharedStepRefs.forEach(function (ref) {
      stepId++;
      xml += '<compref id="' + stepId + '" ref="' + ref.id + '" />';
    });
  }

  // Add regular steps
  steps.forEach(function (step) {
    stepId++;
    xml += '<step id="' + stepId + '" type="ActionStep">';
    xml += "<parameterizedString>" + escapeXml(step.action) + "</parameterizedString>";
    xml += "<parameterizedString>" + escapeXml(step.expected || "") + "</parameterizedString>";
    xml += "</step>";
  });

  xml += "</steps>";
  return xml;
}

function buildParameterXml(parameters) {
  if (!parameters || parameters.length === 0) {
    return "";
  }

  var columns = Object.keys(parameters[0]);
  var xml = '<?xml version="1.0" encoding="utf-8"?>';
  xml += "<NewDataSet>";

  parameters.forEach(function (row) {
    xml += "<Table1>";
    columns.forEach(function (col) {
      xml += "<" + col + ">" + escapeXml(row[col]) + "</" + col + ">";
    });
    xml += "</Table1>";
  });

  xml += "</NewDataSet>";
  return xml;
}

function createSharedSteps(title, steps, tags) {
  var stepsXml = buildStepsXml(steps);
  var patchDoc = [
    { op: "add", path: "/fields/System.Title", value: title },
    { op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml },
  ];

  if (tags) {
    patchDoc.push({ op: "add", path: "/fields/System.Tags", value: tags });
  }

  return makeRequest(
    "POST",
    "/_apis/wit/workitems/$Shared%20Steps?api-version=" + API_VERSION,
    patchDoc
  );
}

function createParameterizedTestCase(title, steps, parameters, sharedStepRefs, priority) {
  var stepsXml = buildStepsXml(steps, sharedStepRefs);
  var patchDoc = [
    { op: "add", path: "/fields/System.Title", value: title },
    { op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml },
    { op: "add", path: "/fields/Microsoft.VSTS.Common.Priority", value: priority || 2 },
  ];

  if (parameters && parameters.length > 0) {
    var paramXml = buildParameterXml(parameters);
    patchDoc.push({
      op: "add",
      path: "/fields/Microsoft.VSTS.TCM.LocalDataSource",
      value: paramXml,
    });
  }

  return makeRequest(
    "POST",
    "/_apis/wit/workitems/$Test%20Case?api-version=" + API_VERSION,
    patchDoc
  );
}

function getSharedStepUsage(sharedStepId) {
  var wiql = {
    query:
      "SELECT [System.Id], [System.Title] FROM WorkItemLinks " +
      "WHERE ([Source].[System.WorkItemType] = 'Test Case') " +
      "AND ([System.Links.LinkType] = 'Microsoft.VSTS.TestCase.SharedStepReferencedBy-Forward') " +
      "AND ([Target].[System.Id] = " + sharedStepId + ") " +
      "MODE (MustContain)",
  };

  return makeRequest(
    "POST",
    "/_apis/wit/wiql?api-version=" + API_VERSION,
    wiql,
    "application/json"
  ).then(function (response) {
    var refs = (response.workItemRelations || []).filter(function (r) {
      return r.source !== null;
    });
    return refs.map(function (r) {
      return r.source.id;
    });
  });
}

function listSharedSteps(tag) {
  var query = "SELECT [System.Id], [System.Title], [System.Tags] " +
    "FROM WorkItems WHERE [System.WorkItemType] = 'Shared Steps'";

  if (tag) {
    query += " AND [System.Tags] CONTAINS '" + tag + "'";
  }

  query += " ORDER BY [System.Title] ASC";

  return makeRequest(
    "POST",
    "/_apis/wit/wiql?api-version=" + API_VERSION,
    { query: query },
    "application/json"
  ).then(function (response) {
    var ids = (response.workItems || []).map(function (wi) {
      return wi.id;
    });

    if (ids.length === 0) {
      return [];
    }

    return makeRequest(
      "GET",
      "/_apis/wit/workitems?ids=" + ids.slice(0, 200).join(",") +
        "&fields=System.Id,System.Title,System.Tags&api-version=" + API_VERSION,
      null,
      "application/json"
    ).then(function (response) {
      return (response.value || []).map(function (wi) {
        return {
          id: wi.id,
          title: wi.fields["System.Title"],
          tags: wi.fields["System.Tags"] || "",
        };
      });
    });
  });
}

// Main execution
var action = process.argv[2] || "demo";

if (action === "demo") {
  console.log("Creating shared steps and parameterized test cases...\n");

  var loginSharedStepId;
  var navSharedStepId;

  // Create shared steps
  createSharedSteps(
    "Login as @role User",
    [
      { action: "Navigate to the login page", expected: "Login page loads" },
      { action: "Enter @username in email field", expected: "Email accepted" },
      { action: "Enter @password in password field", expected: "Password masked" },
      { action: "Click Sign In", expected: "Dashboard loads for @role" },
    ],
    "shared-steps;login;authentication"
  )
    .then(function (sharedStep) {
      loginSharedStepId = sharedStep.id;
      console.log("Created shared step: " + sharedStep.id + " - Login as @role User");

      return createSharedSteps(
        "Navigate to User Management",
        [
          { action: "Click Administration in the sidebar", expected: "Admin menu expands" },
          { action: "Click User Management", expected: "User list page loads" },
          { action: "Verify the user table displays", expected: "Table with user rows visible" },
        ],
        "shared-steps;navigation;admin"
      );
    })
    .then(function (sharedStep) {
      navSharedStepId = sharedStep.id;
      console.log("Created shared step: " + sharedStep.id + " - Navigate to User Management");

      // Create parameterized test case with shared steps
      return createParameterizedTestCase(
        "Verify role-based access to User Management",
        [
          { action: "Verify the permissions match @role", expected: "@expectedPermissions" },
          { action: "Attempt to click 'Add User'", expected: "@addUserResult" },
          { action: "Attempt to click 'Delete' on a user row", expected: "@deleteResult" },
        ],
        [
          {
            role: "Admin",
            username: "[email protected]",
            password: "AdminP@ss1",
            expectedPermissions: "Full access: Add, Edit, Delete visible",
            addUserResult: "Add User dialog opens",
            deleteResult: "Delete confirmation dialog appears",
          },
          {
            role: "Manager",
            username: "[email protected]",
            password: "MgrP@ss1",
            expectedPermissions: "Partial access: Add, Edit visible. Delete hidden",
            addUserResult: "Add User dialog opens",
            deleteResult: "Delete button is not visible",
          },
          {
            role: "Viewer",
            username: "[email protected]",
            password: "ViewP@ss1",
            expectedPermissions: "Read-only: No action buttons visible",
            addUserResult: "Add User button is not visible",
            deleteResult: "Delete button is not visible",
          },
        ],
        [{ id: loginSharedStepId }, { id: navSharedStepId }],
        1
      );
    })
    .then(function (testCase) {
      console.log(
        "Created test case: " + testCase.id + " - " + testCase.fields["System.Title"]
      );
      console.log("\nThis test case has:");
      console.log("  - 2 shared step references (Login + Navigate)");
      console.log("  - 3 custom steps with parameters");
      console.log("  - 3 parameter iterations (Admin, Manager, Viewer)");
      console.log("  - Total test points per configuration: 3");
    })
    .then(function () {
      // Create a second parameterized test case reusing the same shared steps
      return createParameterizedTestCase(
        "Verify login error handling",
        [
          { action: "Verify the error message displayed", expected: "@expectedError" },
          { action: "Verify the user remains on the login page", expected: "Login page still displayed" },
          { action: "Check the failed login counter", expected: "@lockoutStatus" },
        ],
        [
          {
            username: "[email protected]",
            password: "AnyPass1",
            expectedError: "Error: Invalid email or password",
            lockoutStatus: "No lockout warning",
          },
          {
            username: "[email protected]",
            password: "WrongPass",
            expectedError: "Error: Invalid email or password",
            lockoutStatus: "No lockout warning",
          },
          {
            username: "[email protected]",
            password: "",
            expectedError: "Error: Password is required",
            lockoutStatus: "No lockout warning",
          },
          {
            username: "",
            password: "AdminP@ss1",
            expectedError: "Error: Email is required",
            lockoutStatus: "No lockout warning",
          },
          {
            username: "[email protected]",
            password: "AnyPass1",
            expectedError: "Error: Account locked. Contact support",
            lockoutStatus: "Account locked after 5 failed attempts",
          },
        ],
        null,
        1
      );
    })
    .then(function (testCase) {
      console.log(
        "Created test case: " + testCase.id + " - " + testCase.fields["System.Title"]
      );
      console.log("  - 5 parameter iterations for error scenarios");

      console.log("\n=== Setup Complete ===");
    })
    .catch(function (err) {
      console.error("Error: " + err.message);
      process.exit(1);
    });
} else if (action === "list-shared") {
  var tag = process.argv[3] || "";
  listSharedSteps(tag).then(function (steps) {
    console.log("Shared Steps" + (tag ? " (tag: " + tag + ")" : "") + ":");
    steps.forEach(function (s) {
      console.log("  #" + s.id + ": " + s.title + (s.tags ? " [" + s.tags + "]" : ""));
    });
    console.log("\nTotal: " + steps.length);
  }).catch(function (err) {
    console.error("Error: " + err.message);
    process.exit(1);
  });
} else if (action === "usage") {
  var id = parseInt(process.argv[3]);
  if (!id) {
    console.log("Usage: node parameterized-tests.js usage <sharedStepId>");
    process.exit(1);
  }
  getSharedStepUsage(id).then(function (testCaseIds) {
    console.log("Shared Step #" + id + " is referenced by " + testCaseIds.length + " test case(s):");
    testCaseIds.forEach(function (tcId) {
      console.log("  Test Case #" + tcId);
    });
  }).catch(function (err) {
    console.error("Error: " + err.message);
    process.exit(1);
  });
} else {
  console.log("Usage:");
  console.log("  node parameterized-tests.js demo              Create example shared steps and test cases");
  console.log("  node parameterized-tests.js list-shared [tag]  List shared steps, optionally filtered by tag");
  console.log("  node parameterized-tests.js usage <id>         Show test cases using a shared step");
}

Running the demo:

$ node parameterized-tests.js demo
Creating shared steps and parameterized test cases...

Created shared step: 12080 - Login as @role User
Created shared step: 12081 - Navigate to User Management
Created test case: 12082 - Verify role-based access to User Management

This test case has:
  - 2 shared step references (Login + Navigate)
  - 3 custom steps with parameters
  - 3 parameter iterations (Admin, Manager, Viewer)
  - Total test points per configuration: 3

Created test case: 12083 - Verify login error handling
  - 5 parameter iterations for error scenarios

=== Setup Complete ===

Common Issues and Troubleshooting

Parameters Not Substituting in Test Runner

When the test runner shows literal @parameterName text instead of substituted values, the parameter name in the step does not match a column name in the parameter table. Parameter names are case-sensitive -- @Username and @username are different parameters. Verify that the column headers in the parameter table exactly match the @ references in the steps. Also check for trailing spaces in column names, which are invisible but prevent matching.

Shared Steps Showing as "Deleted" in Test Cases

If a shared step work item is deleted or its state is changed to "Removed," test cases that reference it show "[Deleted]" in place of the shared steps. The test case remains functional but the shared steps are not expandable. To fix this, restore the shared step from the recycle bin or create a new shared step and update the test case references. Always check shared step usage before deleting.

Parameter Table Not Saving

The parameter table editor has a limit on the number of columns and rows it can save. Azure DevOps supports up to 32 parameter columns and approximately 100 rows per test case. If you need more rows, consider splitting the test case into multiple test cases with different parameter subsets. Also verify that parameter values do not contain characters that break the XML storage format -- angle brackets (<, >), ampersands (&), and control characters can corrupt the parameter data.

Shared Steps Not Expanding During Test Run

When a tester runs a test case and shared steps appear as a single collapsed line that cannot be expanded, the shared step work item may have been moved to a different project or the tester may lack read access to the shared step. Shared steps are work items that follow the same permission rules as other work items. Verify the tester has read access to the area path containing the shared step.

Iteration Count Mismatch After Adding Shared Parameters

When you link a shared parameter set to a test case that already has local parameters, the iterations may not match expectations. Shared parameters replace local parameters -- they do not combine. If you need both shared and local parameters, the shared parameter set must include all columns needed by the test case, including those used in shared steps.

Best Practices

  • Extract shared steps proactively. Do not wait until you have 50 test cases with duplicated login steps. When you write the second test case that starts with the same procedure, extract it to shared steps immediately. The cost of extraction is minutes; the cost of maintaining duplicates is hours over the test plan's lifetime.

  • Name shared steps with the action and context. "Login as Admin" is a good name. "Shared Steps 1" is not. Testers see shared step names in the test runner and need to understand what the block does without expanding it.

  • Keep shared steps atomic. A shared step should represent one logical procedure: login, navigate to a page, create an entity, verify a state. Do not create shared steps that do login AND navigate AND create -- that makes them too specific to reuse. Break them into separate shared steps and combine them in test cases.

  • Use parameters for data variation, not procedure variation. If different parameter values would require different steps to be executed (branching logic), use separate test cases instead. Parameters should change what data is used, not how the test is performed.

  • Tag shared steps for discoverability. With dozens of shared steps, finding the right one is hard. Use consistent tags: login, navigation, setup, cleanup, validation. Testers can search by tag when inserting shared steps into test cases.

  • Audit shared step usage before editing. Use the REST API or work item queries to check which test cases reference a shared step before making changes. An edit to a widely-used shared step affects every referencing test case immediately. Communicate changes to the team before modifying high-usage shared steps.

  • Limit parameter iterations to meaningful data. A parameter table with 100 rows of similar data provides diminishing returns. Focus on boundary values, equivalence classes, and distinct scenarios. Five well-chosen parameter rows often provide better coverage than 50 random ones.

  • Version shared steps with your release. When a major UI redesign changes navigation, update shared steps in a coordinated batch at the beginning of the sprint. Do not update shared steps mid-sprint while testers are executing test cases that reference them.

References

Powered by Contentful