1. Page objects

Map the application once, as small classes with one locator each, in three levels:

  • The app — one IPageContext class for the whole application, the root everything else is found in.
  • Sections — one ISectionContext class per part of a screen: a form, its result, a table, a menu.
  • Elements — one IElementContext class per field, button or text, nested inside its section.

A static class per screen groups its sections, so FormsPage.Registration.Email says exactly where the field lives. The examples on this page test the sign-up form on /practice/forms, so you can run them yourself.

public class AppPage : IPageContext
{
    public By PageFindMechanism { get; set; } = By.TagName("body");
}

// /practice/forms: one static class per screen, holding the sections of that screen
public static class FormsPage
{
    public class Registration : ISectionContext
    {
        public By SectionFindMechanism { get; set; } = By.Id("registration");

        public class FullName : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("full-name");
        }

        public class Email : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("email");
        }

        public class Password : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("password");
        }

        public class Country : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("country");
        }

        public class AcceptTerms : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("accept-terms");
        }

        public class Submit : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("submit-button");
        }

        public class EmailError : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("email-error");
        }
    }

    // What the server received, shown after a valid form is sent
    public class Result : ISectionContext
    {
        public By SectionFindMechanism { get; set; } = By.Id("result");

        public class Title : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("result-title");
        }

        public class FullName : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("result-full-name");
        }

        public class Email : IElementContext
        {
            public By ElementFindMechanism { get; set; } = By.Id("result-email");
        }
    }
}

Prefer ids and CSS selectors to absolute XPath such as /html/body/div[7]: they survive layout changes, and when a locator does break there is exactly one place to fix it. Good and bad locators shows the difference on one screen.

The same code in VAF-Demo: AppPage.cs, FormsPage.cs

2. Control types

After HaveElement<T>(), say what kind of control the element is. That decides the actions (Lets…) and checks (after Should()) you get. The most used ones:

CallControlActionsChecks
AsButton(), AsMenu()Button, menu itemLetsClick, LetsGetValueHaveValue, ContainValue, BeEnabled, BeDisabled
AsLink()LinkLetsClick, LetsGetHrefValueHaveValue, NotHaveValue
AsTextInputField()Single-line input (text, email, password…)LetsInsert, LetsClear, LetsSendKeyHaveValue, Contain, BeTypeOf
AsTextAreaField()Multi-line text areaLetsInsert, LetsClearHaveValue
AsSelectBox()Drop-down list (<select>)LetsSelect, LetsSelectRandom, LetsGetOptionsBeSelected, HaveOption, HaveOptions
AsCheckBox()Check boxLetsClickBeChecked, BeUnChecked
AsRadioButton()Radio buttonLetsClickBeSelected, BeUnSelected
AsLabelText()Any text: label, heading, messageLetsGetValueHaveValue, Contain, HaveOneOfValue, BeBolded
AsToolTipText()TooltipLetsGetValueHaveValue, BeBolded
AsCounterField()Number shown on the page (counter, badge)LetsGetValueHaveValue
AsTableBody()Body of a table (<tbody>)LetsGetNumberOfRowsHaveNumberOfRows
AsUploadFileField()File inputLetsUploadBeDisabled, BeFocused
AsSlideBar()SliderLetsSetValue, LetsMoveToPercentage
AsDateTimeFlatpickerV4(), AsDateTimeBootstrap3DatepickerV4()Date picker (flatpickr 4, Bootstrap 3 datetimepicker 4)LetsSelectDateHaveValue

Most control types also have LetsTab and the checks BeEnabled, BeDisabled and BeFocused.

3. Shared steps

This is where VAF pays off. First, two small classes hold what every test needs: SharedContext keeps the browser and the page context from DisplayPage<AppPage>().That, and TestBase starts the browser before each test and closes it after, with the report.

public static class SharedContext
{
    public static BrowserActions Browser { get; private set; }
    public static PageActions<AppPage> App { get; private set; }

    public static void LetsStartTheBrowser() => Browser = Testing.On<Chrome>();

    public static void LetsOpen(string path)
    {
        App = Browser.LetsNavigateTo("https://www.visionautomationframework.com" + path)
            .Should().DisplayPage<AppPage>().That;
    }

    public static void LetsCloseTheBrowser()
    {
        Browser?.Scope.Driver.Quit();
        Browser = null;
        App = null;
    }
}

public abstract class TestBase
{
    [SetUp]
    public void StartTheBrowser() => SharedContext.LetsStartTheBrowser();

    [TearDown]
    public void ReportAndClose()
    {
        try
        {
            Testing.ConvertLogToHtml(TestContext.CurrentContext.WorkDirectory,
                                     TestContext.CurrentContext.Test.Name);
        }
        finally
        {
            SharedContext.LetsCloseTheBrowser();
        }
    }
}

Then wrap each fluent chain in a static shared step named after what it does for the user — LetsRegister, LetsCheckTheResult — and let every test use it. Every step starts from SharedContext.App.

public static class FormsSteps
{
    public static void LetsOpenTheForm() => SharedContext.LetsOpen("/practice/forms");

    public static void LetsRegister(string fullName, string email, string password, string country)
    {
        SharedContext.App.Should().HaveSection<FormsPage.Registration>()
            .That.Should().HaveElement<FormsPage.Registration.FullName>()
                .That.AsTextInputField().LetsInsert(fullName)
            .AndAlso().Should().HaveElement<FormsPage.Registration.Email>()
                .That.AsTextInputField().LetsInsert(email)
            .AndAlso().Should().HaveElement<FormsPage.Registration.Password>()
                .That.AsTextInputField().LetsInsert(password)
            .AndAlso().Should().HaveElement<FormsPage.Registration.Country>()
                .That.AsSelectBox().LetsSelect(country)
            .AndAlso().Should().HaveElement<FormsPage.Registration.AcceptTerms>()
                .That.AsCheckBox().LetsClick()
            .AndAlso().Should().HaveElement<FormsPage.Registration.Submit>()
                .That.AsButton().LetsClick();
    }

    public static void LetsCheckTheResult(string fullName, string email)
    {
        SharedContext.App.Should().HaveSection<FormsPage.Result>()
            .That.Should().HaveElement<FormsPage.Result.Title>()
                .That.AsLabelText().Should().HaveValue("Registration received")
            .AndAlso().Should().HaveElement<FormsPage.Result.FullName>()
                .That.AsLabelText().Should().HaveValue(fullName)
            .AndAlso().Should().HaveElement<FormsPage.Result.Email>()
                .That.AsLabelText().Should().HaveValue(email);
    }

    public static string LetsReadTheEmailError()
    {
        return SharedContext.App.Should().HaveSection<FormsPage.Registration>()
            .That.Should().HaveElement<FormsPage.Registration.EmailError>()
                .That.AsLabelText().LetsGetValue();
    }
}

When the form changes, one shared step changes. Every test that registers keeps working, and none of them had to know how registering is done.

The same code in VAF-Demo: SharedContext.cs, TestBase.cs, FormsSteps.cs

4. Tests

Tests derive from TestBase, call shared steps and contain no locators and no fluent chains of their own. They are short, read as scenarios, and data-driven tests come for free from the test framework.

public class FormsTests : TestBase
{
    // TestBase has started Chrome; this opens the page and fills SharedContext.App
    [SetUp]
    public void Open() => FormsSteps.LetsOpenTheForm();

    [Test]
    public void ValidRegistrationIsShownBack()
    {
        FormsSteps.LetsRegister("Ada Lovelace", "ada@example.com", "analytical", "Croatia");
        FormsSteps.LetsCheckTheResult("Ada Lovelace", "ada@example.com");
    }

    [TestCase("ada.example.com", "This does not look like an email address.")]
    [TestCase("", "Please enter your email address.")]
    public void WrongEmailIsReported(string email, string error)
    {
        FormsSteps.LetsRegister("Ada Lovelace", email, "analytical", "Croatia");
        Assert.That(FormsSteps.LetsReadTheEmailError(), Is.EqualTo(error));
    }
}

Using MSTest or xUnit? VAF works with both: the tests are the same, only the attributes differ.

The same code in VAF-Demo: FormsStepsTests.cs

5. Checking and reading values

Assert a value with HaveValue, read one with LetsGetValue when the test needs it later, and use BePresent for content that is only sometimes there — it answers true or false instead of failing.

// Assert a value: fails the test with a clear message when it differs
SharedContext.App.Should().HaveSection<FormsPage.Result>()
    .That.Should().HaveElement<FormsPage.Result.FullName>()
        .That.AsLabelText().Should().HaveValue("Ada Lovelace");

// Read a value to use it later in the test
string error = SharedContext.App.Should().HaveSection<FormsPage.Registration>()
    .That.Should().HaveElement<FormsPage.Registration.EmailError>()
        .That.AsLabelText().LetsGetValue();

// Branch on content that is only sometimes there — returns true or false, never fails
bool received = SharedContext.App.Should().BePresent<FormsPage.Result>(seconds: 3);

6. Waiting

Every assertion waits for its page, section or element before checking it, so tests contain no sleeps and no WebDriverWait. If the application shows a loading indicator, tell VAF once and every step waits for it to disappear:

// The spinner on /practice/loading
public class Spinner : IElementContext
{
    public By ElementFindMechanism { get; set; } = By.Id("loading-spinner");
}

// Once, after the browser has started: every following step waits until the spinner is gone
SharedContext.Browser.LetsUseLoader<Spinner>();

After an action that starts a longer load, such as sending a form, add WaitForLoad():

SharedContext.App.Should().HaveSection<FormsPage.Registration>()
    .That.Should().HaveElement<FormsPage.Registration.Submit>()
        .That.AsButton().LetsClick()
        .WaitForLoad();

Timeouts are in seconds: by default VAF waits up to 10 for an element and up to 60 for the loader. Change them with the environment variables VAF_ElementWaitTimeout and VAF_LoaderWaitTimeout — they work on .NET Framework and .NET 10, locally and in CI:

$env:VAF_ElementWaitTimeout = "20"
$env:VAF_LoaderWaitTimeout = "120"
dotnet test

The same keys also work in appSettings of an app.config, but on .NET 10 the test host often does not load it.

Try it on /practice/loading: results behind a spinner, and a message that appears two seconds later.

The same code in VAF-Demo: LoadingTests.cs

7. Log and HTML report

Every action and assertion is written to the test log as it happens. Turn it into an HTML page at the end of each test, as TestBase above does — when something fails, the report shows the last step that worked.

[TearDown]
public void Report()
{
    // Every action and assertion of the test, as an HTML page next to the results
    Testing.ConvertLogToHtml(TestContext.CurrentContext.WorkDirectory,
                             TestContext.CurrentContext.Test.Name);
}

8. Reading a chain

Testing.On<Chrome>() Starts the browser and the test log. Firefox and Safari work the same way.
.Lets…() Actions: LetsNavigateTo, LetsClick, LetsInsert, LetsSelect, LetsUpload…
.Should() Starts an assertion, which waits for its target before checking it.
.That Moves into what was just asserted, so the next step works inside that page, section or element.
.As…() Says what kind of control the element is.
.AndAlso() Steps back to the enclosing section, so the chain can continue with its next element.