Step by step integration of the WebView2 into tests - nikolaygekht/WebTestTools GitHub Wiki
So, let's test the idea.
I want to simulate a "typical" web UI test scenario through the WebView2
control instantiated inside the application. The scenario will open a google page, search for 'gehtsoft' and make sure that gehtsoft's website is on the first page of the results.
When we create the control for test purposes, we should keep in mind that:
- Webview needs a fully functional message pump to work properly (we can't just create an object and manipulate it, as for the most controls and forms)
- Webview uses COM interop and requires the STA apartment model.
The only prerequisite is to install WebView. Check Microsoft website for the instructions.
Now let's create a WinForms project.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net50-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
</Project>
Add nuget package Microsoft.Web.WebView2
, System.Text.Json
to enable WebView2
. Add also Microsoft.NET.Test.Sdk
, xunit
, xunit.runner.visualstudio
и FluentAssertions
because we are going to run it as tests.
Then create an ordinary form named WebBrowserForm
and place a WebView2
control on the form. Make the WebView2
object public. Also, create a couple of properties for initialization and navigation status.
partial class WebBrowserForm
{
...
public WebView2 WebView => webViewControl;
public bool LoadingCompled { get; private set; } = false;
public bool NavigationCompleted { get; private set; } = false;
The force initialization of WebView2
.
partial class WebBrowserForm
{
...
public WebBrowserForm()
{
InitializeComponent();
webViewControl.EnsureCoreWebView2Async();
}
Subscribe for InitializationCompleted
, NavigationStarting
и NavigationCompleted
events and fill corresponding flags in the handlers.
partial class WebBrowserForm
{
...
private void webViewControl_CoreWebView2InitializationCompleted(object sender,
CoreWebView2InitializationCompletedEventArgs e)
=> LoadingCompled = true;
private void webViewControl_NavigationCompleted(object sender,
CoreWebView2NavigationCompletedEventArgs e)
=> NavigationCompleted = true;
private void webViewControl_NavigationStarting(object sender,
CoreWebView2NavigationStartingEventArgs e)
=> NavigationCompleted = false;
And add navigation methods.
partial class WebBrowserForm
{
...
public void NavigateTo(string url)
{
NavigationCompleted = false;
webViewControl.CoreWebView2.Navigate(url);
}
public void SetContent(string html)
{
NavigationCompleted = false;
webViewControl.CoreWebView2.NavigateToString(html);
}
The form is ready. Now let's pretend that we are a Winforms app. We will need a separate thread to run the message pump. So, let's create a driver class and create a thread in this class. Pay your attention that the thread must be an STA thread.
public sealed class WebBrowserDriver : IDisposable
{
private WebBrowserForm mForm = null;
private Thread mThread = null;
public void Start()
{
if (mThread == null || !mThread.IsAlive)
{
mThread = new Thread(Runner);
mThread.IsBackground = true;
mThread.SetApartmentState(ApartmentState.STA);
mThread.Start();
}
}
public void Dispose()
{
mForm.Dispose();
mForm = null;
}
private void Runner()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(mForm = new WebBrowserForm());
}
}
We must access Winforms objects from the same thread where they are created. So, we would have to access them through the Invoke()
method. Let's create the invocation method to call an Action
and Func<T>
on the form.
public sealed class WebBrowserDriver : IDisposable
{
private void Perform(Action action)
{
if (mForm != null)
{
if (mForm.InvokeRequired)
mForm.Invoke(action);
else
action();
return;
}
throw new InvalidOperationException("The form is not initialized");
}
private T Perform<T>(Func<T> function)
{
if (mForm != null)
{
if (mForm.InvokeRequired)
return (T)mForm.Invoke(function);
else
return function();
}
throw new InvalidOperationException("The form is not initialized");
}
Now we can provide basic operations and close the form at the Dispose()
method.
public sealed class WebBrowserDriver : IDisposable
{
public void Close() => Perform(() => mForm.Close());
public bool HasCore
{
get => Perform(() => mForm.WebView.CoreWebView2 != null);
}
public bool NavigationCompleted
{
get => Perform(() => mForm.NavigationCompleted);
}
public void Navigate(string url)
{
Perform(() => mForm.NavigateTo(url));
while (!mForm.NavigationCompleted)
Thread.Yield();
}
public void SetContent(string content)
{
Perform(() => mForm.SetContent(content));
while (!mForm.NavigationCompleted)
Thread.Yield();
}
public void Show(bool show) => Perform(() => mForm.Visible = show);
public void Start(int timeout = 1000)
{
if (mThread == null || !mThread.IsAlive)
{
mThread = new Thread(Runner);
mThread.IsBackground = true;
mThread.SetApartmentState(ApartmentState.STA);
mThread.Start();
DateTime end = DateTime.Now.AddMilliseconds(timeout);
while (mForm == null || !mForm.Visible || !mForm.LoadingCompled)
{
Thread.Yield();
if (DateTime.Now >= end)
throw new TimeoutException();
}
}
}
public void Dispose()
{
if (mThread.IsAlive)
Close();
mForm.Dispose();
mForm = null;
}
So we are ready for the first check. Let's create our Test
class.
public class Test
{
[Fact]
public void Do()
{
using var f = new WebBrowserDriver();
f.Start();
f.HasCore.Should().BeTrue();
f.Show(true);
f.Navigate("https://www.google.com");
}
}
Run the test via Test Explorer (or from the command line via dotnet test
command). Yep, it works. You can see the form briefly appears. If you stop it before the end using a breakpoint, you could see a google page displayed.
So, we can insert a WebView2
control into a test. But would it be enough to replace Selenium? Not yet. Let's try to teach our test to read the page and manipulate the objects, at least at the level of searching for the element, setting the value, and clicking the items.
WebView2
does not provide access to DOM, but it allows us to run JavaScript. Let's add methods to run the scripts. One will return a raw JSON result as a string. The other will parse JSON into a C# object.
public sealed class WebBrowserDriver : IDisposable
{
public string ExecuteScriptRaw(string script)
{
var task = Perform(() => mForm.WebView.ExecuteScriptAsync(script));
task.Wait();
while (!mForm.NavigationCompleted)
Task.Yield();
if (task.Exception != null)
throw task.Exception;
return task.Result;
}
public T ExecuteScript<T>(string script)
{
string s = ExecuteScriptRaw(script);
if (s == null || s == "null")
return default(T);
return JsonConvert.DeserializeObject<T>(s);
}
}
To keep the driver class neat, let's continue in an extension class. Let's start with XPath support.
public static class WebBrowserDriverExtensions
{
public static string EvaluateXPathString(this WebBrowserDriver driver,
string xpath)
=> driver.ExecuteScript<string>($"document.evaluate(\"{xpath}\", document, null, XPathResult.STRING_TYPE).stringValue");
public static double EvaluateXPathNumber(this WebBrowserDriver driver,
string xpath)
=> driver.ExecuteScript<double>($"document.evaluate(\"{xpath}\", document, null, XPathResult.NUMBER_TYPE).numberValue");
public static bool EvaluateXPathBool(this WebBrowserDriver driver,
string xpath)
=> driver.ExecuteScript<bool>($"document.evaluate(\"{xpath}\", document, null, XPathResult.BOOLEAN_TYPE).booleanValue");
Let's check how XPath works.
public class Test
{
[Fact]
public void Do()
{
…
//check that button exists
f.EvaluateXPathBool("count(/html/body//input[@name='btnK']) > 0").Should().BeTrue();
//check button name attribute
f.EvaluateXPathString("/html/body//input[@name='btnK']/@name").Should().Be("btnK");
f.EvaluateXPathNumber("count(/html/body//input[@name='btnK'])").Should().BeGreaterThan(0);
}
}
It works! Now let's appropriate the By
approach for element specification from Selenium :-).
public sealed class Element
{
public enum LocatorTypes
{
Name,
Id,
XPath,
}
public LocatorTypes LocatorType { get; }
public string Locator { get; }
public int? Index { get; }
private Element(LocatorTypes locatorType, string locator, int? index = null)
{
LocatorType = locatorType;
Locator = locator;
Index = index;
}
public static Element ByName(string name, int index = 0) => new Element(LocatorTypes.Name, name, index);
public static Element ById(string id) => new Element(LocatorTypes.Id, id);
public static Element ByXPath(string expression) => new Element(LocatorTypes.XPath, expression);
internal string CreateAccessor()
{
return LocatorType switch
{
LocatorTypes.Id => $"document.getElementById('{Locator}')",
LocatorTypes.Name => $"document.getElementsByName('{Locator}')[{Index}]",
LocatorTypes.XPath => $"document.evaluate(\"{Locator}\", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE)",
_ => throw new InvalidOperationException($"Unsupported locator type {LocatorType}")
};
}
internal string CheckIfExists()
{
return LocatorType switch
{
LocatorTypes.Id => $"document.getElementById('{Locator}') != null",
LocatorTypes.Name => $"document.getElementsByName('{Locator}').length > 0",
LocatorTypes.XPath => $"document.evaluate(\"count({Locator})>0\", document, null, XPathResult.BOOLEAN_TYPE).booleanValue",
_ => throw new InvalidOperationException($"Unsupported locator type {LocatorType}")
};
}
}
Now we can add checking for element existence, setting the value, and click to WebBrowserDriverExtensions
.
public static class WebBrowserDriverExtensions
{
public static bool Exists(this WebBrowserDriver driver, Element locator)
=> driver.ExecuteScript<bool>(locator.CheckIfExists());
public static void SetValue(this WebBrowserDriver driver,
Element locator, string value)
=> driver.ExecuteScriptRaw($"{locator.CreateAccessor()}.value = '{value}'");
public static void Click(this WebBrowserDriver driver, Element locator)
=> driver.ExecuteScriptRaw($"{locator.CreateAccessor()}.click()");
Let's complete the scenario... but wait, we would have to wait while navigation completes, so let's add a couple more methods: check the current URL and another to wait.
public static class WebBrowserDriverExtensions
{
public static string Location(this WebBrowserDriver driver)
=> driver.ExecuteScript<string>("document.location.href");
public static void WaitFor(this WebBrowserDriver driver,
Func<WebBrowserDriver, bool> signal,
double? timeoutInSeconds = null)
{
DateTime start = DateTime.Now;
TimeSpan? waitTime = timeoutInSeconds != null ?
TimeSpan.FromSeconds((double)timeoutInSeconds) : null;
while (!signal(driver))
{
Thread.Sleep(1);
if (waitTime != null && (DateTime.Now - start) > waitTime.Value)
throw new TimeoutException();
}
}
Ok, now we are back to the scenario. Once again:
- Open google
- Enter 'gehtsoft' keyword
- Click 'Search'
- Wait until the page is loaded
- Check that gehtsoft's link appears on the first page.
public class Test
{
[Fact]
public void Do()
{
using var f = new WebBrowserDriver();
f.Start();
f.Navigate("https://www.google.com");
f.SetValue(Element.ByName("q"), "gehtsoft");
f.Click(Element.ByName("btnK"));
f.WaitFor(d => d.Location().StartsWith("https://www.google.com/search"), 1);
f.EvaluateXPathBool("count(/html/body//cite[text()='https://gehtsoftusa.com']) > 0").Should().BeTrue();
}
The scenario works and takes just under 1.5 seconds to complete. No additional actions aside from installing WebView2 are required, and no additional processes are created during the test. The test is 8-10 times faster than a similar selenium test and is tolerant to mouse manipulations and putting the test window in the background. I would consider the concept is proven.