Search code examples
c#seleniumwinappdriver

UI Test that can determine which classes to execute code from at runtime using interfaces


So just some background on how the current UI automation solution works -

Our application is a Windows WPF app, so we utilize WinAppDriver for our automated testing needs. The solution for this is very similar to your typical UI automation page object design. We have page objects that reference elements, and then in our tests we call the methods from these page objects to perform actions on the host. The page objects make use of the C# partial classes. One class to store elements, one class to use these elements and perform actions

The test classes all inherit from a TestClassBase that handles the StartUp and TearDown login. So current design for something like a Login page and a test class that interacts with it looks like this

Login.Elements.cs

namespace UITesting
{
    public partial class Login
    {

        public WindowsElement usernameField => _session.FindElementByAccessibilityId("UserName");
        public WindowsElement passwordField => _session.FindElementByAccessibilityId("Password");
        public WindowsElement signInButton => _session.FindElementByAccessibilityId("Sign In");

    }
}

Login.Actions.cs

namespace UITesting
{
    public partial class Login
    {
        // Driver Setup
        private readonly WindowsDriver<WindowsElement> _session;
        public Login(WindowsDriver<WindowsElement> session) => _session = session;

        // Enter Username
        public void EnterUsername(string username)
        {
            usernameField.SendKeys(username);
        }

        // Enter Password
        public void EnterPassword(string password)
        {
            passwordField.SendKeys(password)
        }

        // Click 'Sign In'
        public void SignIn()
        {
            signInButton.Click();
        }

    }
}

LoginTests.cs

namespace UITesting.Test
{
    [Category("Login Tests")]
    class LoginTests : TestClassBase
    {

        [Test]
        public void Login()
        {

            // Login
            login.EnterUsername("TestUser1");
            login.EnterPassword("Password");
            login.ClickSignIn();

        }

    }
}

TestClassBase

namespace UITesting
{
    [TestFixture]
    public class TestClassBase
    {

        // Declare Page Ogjects
        public Login login;

        // Declare WinAppDriver Session
        private WindowsDriver<WindowsElement> session;

        [SetUp]
        public void SetUp()
        {

            // Instantiate Page Objects
            login = new Login(session);

            // Additional SetUp Logic here...

        }

        [TearDown]
        public void TearDown()
        {
            // TearDown Logic here...
        }

    }
}

This all works well and great, but what I am trying to do is evolve this into is something that can run the exact same test using the same code on a different host.

We also have a Web version of the app that utilizes the Uno platform. The app is pretty much identical on the web, but to automate it we need to use Selenium. What I don't want to do is to have to manage two separate UI automation solutions, and since the two versions of the app are pretty much identical, I want to be able to toggle the target platform that the tests run on in our CI/CD pipeline and this will ultimately change what code is getting executed.

So it seems like utilizing Interfaces is probably the way to go here, and I understand that using them it would be possible to now have a Page Object class structure like below

ILogin.cs  
LoginWeb.Actions.cs 
LoginWeb.Elements.cs 
LoginWPF.Actions.cs  
LoginWPF.Elements.cs

This way, I now have 4 partial classes where the Actions classes inherit the interface and they use the elements from their corresponding Elements class.

The part that I don't understand is how I can get the test class to now execute the code from the desired Actions class. The part where I instantiate the page objects is key, as in this example both the WPF and Web page object would need to share the name login. Would I have to create two different TestClassBase classes and some sort of Interface for them and have the tests inherit both? Or am I just going about this the completely wrong way..


Solution

  • This might be a larger refactoring job, but it will be worth the effort.

    First, you'll need to create interfaces for each page model. I recommend keeping the interfaces as simple as possible in order to provide a complete and flexible abstraction. Instead of three separate methods (EnterUsername, EnterPassword and ClickSignIn) which must be called in a specific order, consider a single method called SignIn which accepts a username and password as arguments. The method will internally handle entering the username, password and clicking the appropriate button.

    Really, if you go this route, think hard about the interfaces. Try to avoid any situation where the order methods are called matters. Try to focus on the use case, and not the steps required to satisfy that use case.

    public interface ILoginPage
    {
        void SignIn(string username, string password);
    }
    

    Next, implement this interface on two different classes. Each class will specialize in Selenium or WinAppDriver. Consider using a naming convention where page models that deal with the web application are prefixed with "Web" and page models for the desktop app are prefixed with "Windows" or "Desktop".

    public class WebLoginPage : ILoginPage
    {
        private readonly IWebDriver driver;
    
        public WebLoginPage(IWebDriver driver)
        {
            this.driver = driver;
        }
    
        public void SignIn(string username, string password)
        {
            // Enter username
            // Enter password
            // Click sign-in button
        }
    }
    
    public class DesktopLoginPage : ILoginPage
    {
        private readonly WindowsDriver<WindowsElement> session;
    
        public DesktopLoginPage (WindowsDriver<WindowsElement> session)
        {
            this.session = session;
        }
    
        public void SignIn(string username, string password)
        {
            // Enter username
            // Enter password
            // Click sign-in button
        }
    }
    

    Once you have a proper abstraction, you will need an interface for a factory class that creates page models, and then two implementing classes:

    public interface IPageModelFactory
    {
        ILoginPage CreateLoginPage();
    }
    
    public class WebPageModelFactory : IPageModelFactory
    {
        private readonly IWebDriver driver;
    
        public PageModelFactory(IWebDriver driver)
        {
            this.driver = driver;
        }
    
        public ILoginPage CreateLoginPage()
        {
            return new WebLoginPage(driver);
        }
    }
    
    public class DesktopPageModelFactory : IPageModelFactory
    {
        private readonly WindowsDriver<WindowsElement> session;
    
        public DesktopPageModelFactory(WindowsDriver<WindowsElement> session)
        {
            this.session = session;
        }
    
        public ILoginPage CreateLoginPage()
        {
            return new DesktopLoginPage(session);
        }
    }
    

    This is an implementation of the Abstract Factory Pattern, and is an approach you can take without resorting to class reflection. While class reflection would probably take less code, it is much more difficult to understand. Just for giggles, here is an attempt at class reflection to generate page models:

    public class PageModelFactory
    {
        private readonly object client;
    
        public PageModelFactory(object client)
        {
            this.client = client;
        }
    
        public ILoginPage CreateLoginPage()
        {
            var pageModelType = GetPageModelType<ILoginPage>();
            var constructor = pageModelType.GetConstructor(new Type[] { client.GetType() });
    
            return (ILoginPage)constructor.Invoke(new object[] { client });
        }
    
        private Type GetPageModelType<TPageModelInterface>()
        {
            return client.GetType()
                         .Assembly
                         .GetTypes()
                         .Single(type => type.IsClass && typeof(TPageModelInterface).IsAssignableFrom(type));
        }
    }
    

    You can use it with either driver:

    // Selenium
    var driver = new ChromeDriver();
    
    // WinApDriver (however you initialize it)
    var session = new WindowsDriver<WindowsElement>();
    
    PageModelFactory webPages = new PageModelFactory(driver);
    PageModelFactory desktopPages = new PageModelFactory(session);
    ILoginPage loginPage = null;
    
    loginPage = webPages .CreateLoginPage();
    loginPage.SignIn("user", "...");
    
    loginPage = desktopPages.CreateLoginPage();
    loginPage.SignIn("user", "...");
    

    Unless you or your team are comfortable with class reflection, I would recommend the abstract factory pattern approach, just because it is easier to understand.

    Either way, you will need to determine which client you are using (web versus desktop). This should be done in a the setup method for your test. Refactoring your tests into a base class to centralize this decision making code is advised.