ScreenshotNeo

BlogGuides

Page Object Model in Selenium with C#: A Tutorial

Learn to build maintainable Selenium tests in C# with page objects, reusable components, clear assertion boundaries, and a complete login example.

By the ScreenshotNeo team4 October 202610 min read

The Page Object Model (POM) is a way to organize Selenium tests by representing a web page or reusable page section as an object. That object owns the locators and actions for its part of the interface; the test uses those actions and checks the application’s behavior. This keeps page-specific HTML knowledge from being repeated throughout a test suite.

In C#, create the WebDriver in your test setup, pass it to page objects, keep locators private, and expose methods that describe user intent. For example, a LoginPage can provide LoginAs and return a HomePage; the test then asserts that the expected result is visible. Selenium’s POM guide demonstrates the principles in Java, so the C# code below is an application of those principles, not a C# sample from that page. Check the current Selenium .NET API documentation for exact API signatures.

1. What a page object should own

A page object is an object-oriented interface to a page or application component. It centralizes knowledge about that region’s structure and the operations a user can perform there. Tests call methods such as EnterCredentials or SubmitLogin rather than repeating selectors and low-level browser commands.

  • Page object: private locators, page-specific browser interactions, and useful information exposed to callers.
  • Test: the scenario being exercised and assertions about expected application behavior.
  • Test setup: driver creation, configuration, and cleanup.

This boundary makes tests easier to read and localizes maintenance when the UI changes. It does not eliminate maintenance: if the application changes, the affected page object may need updating. Selenium’s guidance says that public methods should represent the services offered by a page or component, and that page objects should generally not contain test assertions. It recognizes checking that the expected page loaded as a reasonable exception during construction.

2. Create a C# Selenium project

Selenium’s documented .NET test-suite path lists .NET SDK 8.0 or later. Its separate, standalone file-based HelloSelenium.cs path requires .NET 10 or later. These are different setup options; a normal test project does not inherit the standalone script’s .NET 10 requirement. Check the current Selenium getting-started instructions before choosing a target framework, because setup guidance can change.

For a new NUnit project, install the packages and WebDriver for the browser you intend to run. The following commands create a project and add Selenium and NUnit dependencies:

dotnet new nunit -n SeleniumPomDemo
cd SeleniumPomDemo
dotnet add package Selenium.WebDriver
dotnet add package Selenium.Support

The Selenium .NET API documentation lists the Selenium.WebDriver and Selenium.Support modules. The example below uses NUnit and Chrome. Selenium Manager can help resolve a compatible driver in supported Selenium setups; consult the current Selenium setup docs for browser and driver requirements.

3. Build a page object

Keep selectors private and expose operations that describe what the user does. This example uses a login form with email and password inputs and a submit button. Replace the example selectors with the application’s actual markup.

using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;

public sealed class LoginPage
{
    private readonly IWebDriver driver;
    private readonly WebDriverWait wait;

    private readonly By emailInput = By.Name("email");
    private readonly By passwordInput = By.Name("password");
    private readonly By submitButton = By.CssSelector("button[type='submit']");

    public LoginPage(IWebDriver driver)
    {
        this.driver = driver;
        wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));

        // A page object may check that its expected page has loaded.
        wait.Until(d => d.FindElement(emailInput).Displayed);
    }

    public HomePage LoginAs(string email, string password)
    {
        driver.FindElement(emailInput).SendKeys(email);
        driver.FindElement(passwordInput).SendKeys(password);
        driver.FindElement(submitButton).Click();
        return new HomePage(driver);
    }

    public LoginErrorPage SubmitInvalidLogin(string email, string password)
    {
        driver.FindElement(emailInput).SendKeys(email);
        driver.FindElement(passwordInput).SendKeys(password);
        driver.FindElement(submitButton).Click();
        return new LoginErrorPage(driver);
    }
}

public sealed class HomePage
{
    private readonly IWebDriver driver;
    private readonly By heading = By.CssSelector("main h1");

    public HomePage(IWebDriver driver)
    {
        this.driver = driver;
        new WebDriverWait(driver, TimeSpan.FromSeconds(10))
            .Until(d => d.FindElement(heading).Displayed);
    }

    public string HeadingText => driver.FindElement(heading).Text;
}

public sealed class LoginErrorPage
{
    private readonly IWebDriver driver;
    private readonly By errorMessage = By.CssSelector("[role='alert']");

    public LoginErrorPage(IWebDriver driver)
    {
        this.driver = driver;
    }

    public string ErrorText => new WebDriverWait(driver, TimeSpan.FromSeconds(10))
        .Until(d => d.FindElement(errorMessage).Displayed)
        .Text;
}

The method names and page classes are examples, not Selenium-defined APIs. A constructor’s page-load check should be limited to a condition that establishes that the object represents the expected page. Keep application-specific expectations—such as whether a particular account should be welcomed—in the test.

4. Keep assertions in the test

The test describes the scenario and verifies its outcome. It does not need to know the login form’s selectors:

using NUnit.Framework;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;

[TestFixture]
public sealed class LoginTests
{
    private IWebDriver driver = null!;

    [SetUp]
    public void SetUp()
    {
        driver = new ChromeDriver();
        driver.Manage().Timeouts().ImplicitWait = TimeSpan.Zero;
        driver.Navigate().GoToUrl("https://example.test/login");
    }

    [TearDown]
    public void TearDown()
    {
        driver.Quit();
        driver.Dispose();
    }

    [Test]
    public void Valid_credentials_open_the_home_page()
    {
        var login = new LoginPage(driver);
        var home = login.LoginAs("reader@example.test", "example-password");

        Assert.That(home.HeadingText, Is.EqualTo("Home"));
    }

    [Test]
    public void Invalid_credentials_show_an_error()
    {
        var login = new LoginPage(driver);
        var errorPage = login.SubmitInvalidLogin("reader@example.test", "wrong-password");

        Assert.That(errorPage.ErrorText, Is.Not.Empty);
    }
}

example.test and the credentials are placeholders; use a test environment and test-only accounts. The assertions are intentionally outside the page objects. For a failed login, the error page object exposes the observed error text, while the test decides what text or behavior is acceptable.

Save the page classes and test in the project, then run:

dotnet restore
dotnet test

For repeatable CI runs, configure browser installation and execution in the CI environment and use test credentials from its secret store. Keep driver cleanup in teardown so it runs after failures, and avoid sharing one mutable WebDriver instance across parallel tests.

5. Compose reusable page components

A page object does not need to represent an entire URL or screen. Repeated regions such as a navigation bar, account menu, or product card can be component objects. A page can compose these objects so their selectors and actions are reused without a giant base class.

using OpenQA.Selenium;

public sealed class SiteNavigation
{
    private readonly IWebDriver driver;
    private readonly By accountLink = By.CssSelector("nav a.account");

    public SiteNavigation(IWebDriver driver) => this.driver = driver;

    public void OpenAccount() => driver.FindElement(accountLink).Click();
}

public sealed class CatalogPage
{
    public SiteNavigation Navigation { get; }

    public CatalogPage(IWebDriver driver)
    {
        Navigation = new SiteNavigation(driver);
    }
}

Prefer composition when a component is used on several pages. Avoid exposing every low-level WebDriver detail or hiding page behavior behind a broad inheritance hierarchy. The caller should be able to understand what service a page or component offers.

6. Choose locators and waits deliberately

Use stable attributes intended for testing, such as an application’s test identifier, when available. Otherwise prefer selectors that describe the element reliably, such as a unique name or accessible role where supported by the markup. Keep each locator close to the page or component that owns it.

  • Use explicit waits for a meaningful state such as visibility or clickability before interacting with an element that appears asynchronously.
  • Keep implicit waits at zero when relying on explicit waits, or understand how the two wait styles interact; mixed waits can make timeouts difficult to predict.
  • Wait for navigation results in the destination page object or with a condition appropriate to the application. A click returning does not necessarily mean a single-page application has finished updating.
  • Use a CSS selector or XPath only when it makes the page object clearer and the selector is stable enough for the application.

For a large page, expose meaningful operations rather than a public field or property for every element. A property can be appropriate for information that callers need to assert, such as a heading or error message.

7. Take a screenshot when a browser test fails

A screenshot can help diagnose a failed browser test by showing what the browser rendered at the point of failure. Keep screenshot capture in test infrastructure or a failure hook rather than turning a page object into a general-purpose test runner.

using OpenQA.Selenium;

public static class FailureArtifacts
{
    public static void SaveScreenshot(IWebDriver driver, string path)
    {
        if (driver is ITakesScreenshot screenshotDriver)
        {
            var screenshot = screenshotDriver.GetScreenshot();
            screenshot.SaveAsFile(path);
        }
    }
}

Call this helper from a test framework failure hook before quitting the driver. Choose a unique path per test and ensure the artifact directory exists. For a screenshot of a public page without managing a browser session, ScreenshotNeo is a website screenshot API and MCP server for developers. See its website and API documentation.

8. Or skip the browser setup

If your task is to capture a page rather than exercise an interactive user journey, a screenshot API can avoid maintaining browser setup for that capture. ScreenshotNeo accepts a URL in one GET request and returns an image or PDF. This cURL example saves a WebP screenshot:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Cookie banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. The MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. Every feature is available on every plan.

Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
with open("shot.webp", "wb") as f:
    f.write(r.content)

Node.js:

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

In Node.js environments without Bun, write the response bytes using the runtime’s filesystem API. See the ScreenshotNeo docs for authentication, output formats, capture options, and response headers. Sign up for 1,000 free screenshots a month with no card.

9. Common problems and fixes

Problem Likely cause Fix
Element not found Wrong selector, different page state, or element rendered later Inspect the rendered DOM, confirm the page object matches the current page, and wait for the correct condition.
Element is not interactable Element is hidden, covered, disabled, or not yet ready Wait for visibility or enabled state, and use the actual visible control rather than a hidden input.
Wait timeout Expected state never occurred, selector is wrong, or timeout is too short Check the application response and locator first; then choose a suitable condition and timeout.
Test passes alone but fails in suite Shared browser state, test data collision, or parallel access to one driver Use isolated test data and a separate driver per test; make setup and cleanup deterministic.
Browser/driver session fails to start Missing browser, incompatible driver, or CI environment not configured Install/configure a supported browser and driver using current Selenium setup guidance; inspect startup logs.
Assertion lives in page object Page object has become coupled to one test’s expected outcome Expose observed state or an operation; move the scenario assertion into the test.

10. Performance, reliability, and cost

In a test suite, page objects do not make browser sessions faster by themselves. Their value is clearer ownership of selectors and actions, which can reduce repeated code and make UI changes easier to localize. Browser startup and network activity often dominate test duration; reuse a session only when the test framework’s isolation model permits it, and avoid sharing a mutable driver among parallel tests.

Reliability improves when tests wait for application states rather than fixed delays, isolate their data, and capture useful failure artifacts. Avoid arbitrary sleeps as the main synchronization mechanism. Keep page objects small enough that each represents a coherent page or component, and keep assertions visible in test code.

Running Selenium locally or in CI has infrastructure costs such as browser execution time and maintenance of the environment; the exact cost depends on the environment and is not fixed by the POM pattern. ScreenshotNeo has a free tier of 1,000 shots per month and paid tiers of Starter $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000. Yearly billing gives two months free. Only clean shots are billed, and all features are on every plan.

11. Frequently asked questions

Does Selenium provide a C# Page Object Model base class?

POM is an organization pattern, not a Selenium base class. Define page and component classes that fit your application and framework.

Should every URL have a page object?

No. Model the pages and components that give tests a useful interface. A route can share a page structure, and one screen can contain several reusable components.

Can page objects contain assertions?

Keep application behavior assertions in tests. A page object may check that its expected page loaded when it is created, then expose information for the test to verify.

Which IDE should I use?

Selenium’s code organization guide mentions Rider and Visual Studio Code as options, but does not require a particular IDE. Choose one that supports your .NET workflow.

Further reading