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 screen or component: a header, a sign-in form, a grid.
  • Elements — one IElementContext class per field, button or text, nested inside its section, so LoginForm.Email says exactly where the field lives.
public class AppPage : IPageContext
{
    public By PageFindMechanism { get; set; } = By.TagName("body");
}

public class LoginForm : ISectionContext
{
    public By SectionFindMechanism { get; set; } = By.Id("login-form");

    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 Submit : IElementContext
    {
        public By ElementFindMechanism { get; set; } = By.CssSelector("button[type='submit']");
    }

    public class Error : IElementContext
    {
        public By ElementFindMechanism { get; set; } = By.CssSelector(".alert-danger");
    }
}
public class Header : ISectionContext
{
    public By SectionFindMechanism { get; set; } = By.CssSelector("header");

    public class SignIn : IElementContext
    {
        public By ElementFindMechanism { get; set; } = By.LinkText("Sign in");
    }

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

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.

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. Wrap each fluent chain in a static shared step named after what it does for the user — LetsSignIn, LetsCheckSignedInAs — and let every test use it. The page context from DisplayPage<AppPage>().That is kept once and every step starts from it.

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

    public static void LetsOpenTheShop(string url)
    {
        Browser = Testing.On<Chrome>().LetsUseLoader<Spinner>();
        App = Browser.LetsNavigateTo(url)
            .Should().DisplayPage<AppPage>().That;
    }

    public static void LetsSignIn(string email, string password)
    {
        App.Should().HaveSection<Header>()
            .That.Should().HaveElement<Header.SignIn>()
                .That.AsLink().LetsClick();

        App.Should().HaveSection<LoginForm>()
            .That.Should().HaveElement<LoginForm.Email>()
                .That.AsTextInputField().LetsInsert(email)
            .AndAlso().Should().HaveElement<LoginForm.Password>()
                .That.AsTextInputField().LetsInsert(password)
            .AndAlso().Should().HaveElement<LoginForm.Submit>()
                .That.AsButton().LetsClick();
    }

    public static void LetsCheckSignedInAs(string name)
    {
        App.Should().HaveSection<Header>()
            .That.Should().HaveElement<Header.UserName>()
                .That.AsLabelText().Should().HaveValue(name);
    }

    public static string LetsReadLoginError()
    {
        return App.Should().HaveSection<LoginForm>()
            .That.Should().HaveElement<LoginForm.Error>()
                .That.AsLabelText().LetsGetValue();
    }

    public static void LetsCloseTheBrowser() => Browser.Scope.Driver.Quit();
}

When the sign-in form changes, one shared step changes. Every test that signs in keeps working, and none of them had to know how signing in is done.

4. Tests

Tests 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.

[TestFixture]
public class SignInTests
{
    [SetUp]
    public void OpenShop() => SharedSteps.LetsOpenTheShop("https://shop.example.com");

    [TearDown]
    public void Report()
    {
        Testing.ConvertLogToHtml(TestContext.CurrentContext.WorkDirectory,
                                 TestContext.CurrentContext.Test.Name);
        SharedSteps.LetsCloseTheBrowser();
    }

    [Test]
    public void SignsInWithValidCredentials()
    {
        SharedSteps.LetsSignIn("qa@example.com", "not-a-real-password");
        SharedSteps.LetsCheckSignedInAs("QA Engineer");
    }

    [TestCase("qa@example.com", "wrong-password")]
    [TestCase("unknown@example.com", "not-a-real-password")]
    public void RejectsInvalidCredentials(string email, string password)
    {
        SharedSteps.LetsSignIn(email, password);
        Assert.That(SharedSteps.LetsReadLoginError(), Does.Contain("Invalid"));
    }
}

Using MSTest? VAF works with it as well. Add this to the test class:

[TestInitialize]
public void Initialize()
{
    // VAF asserts with NUnit internally. Under MSTest this clears NUnit's result
    // state, so a failure in one test does not carry over into the next one.
    new NUnit.Framework.Internal.TestExecutionContext.IsolatedContext();
}

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
App.Should().HaveSection<Header>()
    .That.Should().HaveElement<Header.UserName>()
        .That.AsLabelText().Should().HaveValue("QA Engineer");

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

// Branch on content that is only sometimes there — returns true or false, never fails
bool shown = App.Should().HaveSection<LoginForm>()
    .That.Should().BePresent<LoginForm.Error>(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:

public class Spinner : IElementContext
{
    public By ElementFindMechanism { get; set; } = By.CssSelector(".spinner");
}

// Every following step waits until the spinner is gone
Testing.On<Chrome>()
    .LetsUseLoader<Spinner>()
    .LetsNavigateTo("https://shop.example.com");

After an action that starts a longer load, add WaitForLoad():

App.Should().HaveSection<LoginForm>()
    .That.Should().HaveElement<LoginForm.Submit>()
        .That.AsButton().LetsClick()
        .WaitForLoad();

Timeouts come from app.config of the test project, in seconds:

<appSettings>
  <add key="ElementWaitTimeout" value="10" />
  <add key="LoaderWaitTimeout" value="60" />
</appSettings>

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 — 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.