Guide
How a VAF suite is built, in the order you build it: map the UI as page objects, turn user actions into shared steps, write tests that read like scenarios.
1. Page objects
Map the application once, as small classes with one locator each, in three levels:
-
The app — one
IPageContextclass for the whole application, the root everything else is found in. -
Sections — one
ISectionContextclass per part of a screen: a form, its result, a table, a menu. -
Elements — one
IElementContextclass 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:
| Call | Control | Actions | Checks |
|---|---|---|---|
AsButton(), AsMenu() | Button, menu item | LetsClick, LetsGetValue | HaveValue, ContainValue, BeEnabled, BeDisabled |
AsLink() | Link | LetsClick, LetsGetHrefValue | HaveValue, NotHaveValue |
AsTextInputField() | Single-line input (text, email, password…) | LetsInsert, LetsClear, LetsSendKey | HaveValue, Contain, BeTypeOf |
AsTextAreaField() | Multi-line text area | LetsInsert, LetsClear | HaveValue |
AsSelectBox() | Drop-down list (<select>) | LetsSelect, LetsSelectRandom, LetsGetOptions | BeSelected, HaveOption, HaveOptions |
AsCheckBox() | Check box | LetsClick | BeChecked, BeUnChecked |
AsRadioButton() | Radio button | LetsClick | BeSelected, BeUnSelected |
AsLabelText() | Any text: label, heading, message | LetsGetValue | HaveValue, Contain, HaveOneOfValue, BeBolded |
AsToolTipText() | Tooltip | LetsGetValue | HaveValue, BeBolded |
AsCounterField() | Number shown on the page (counter, badge) | LetsGetValue | HaveValue |
AsTableBody() | Body of a table (<tbody>) | LetsGetNumberOfRows | HaveNumberOfRows |
AsUploadFileField() | File input | LetsUpload | BeDisabled, BeFocused |
AsSlideBar() | Slider | LetsSetValue, LetsMoveToPercentage | |
AsDateTimeFlatpickerV4(), AsDateTimeBootstrap3DatepickerV4() | Date picker (flatpickr 4, Bootstrap 3 datetimepicker 4) | LetsSelectDate | HaveValue |
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. |