Testing is an important part of a developers responsibilities, especially in a fast moving agile world! This is the first post in a series on testing patterns I used and discovered when testing applications using XAF and XPO applications.
Although the patterns I use are focusing on testing XAF and XPO applications, most of them may apply to not only on testing and not only on XAF applications. After learning about test data in the last post we now can focus on functional tests. So let's get started!
Functional Tests
So let's think about what functional tests are.
- Simulate user input
- Assert behavior
Okay seams legit, but how can you actually to do that?
There are several technologies available, depending on the platform. For the web, I did a lot of testing in the past with Selenium. At my last job at Ranorex we wrote a whole product around Selenium called Webtestit! (That's awesome by the way, check it out). From DevExpress there is TestCafe but I didn't had a chance to use it in a real world project yet. Puppeteer is also an valuable tool (esp. for headless testing).
For Windows-Desktop there are another load of options. There is Ranorex Studio, Coded-UI-Tests from Microsoft, WinAppDriver also from Microsoft, Project Sikuli and of course there is EasyTest from DevExpress.
To be honest I never looked into EasyTest until now, cause I don't like recorded tests. Those are incredible hard to maintain. That is not special to EasyTest it self. Almost all tools I mentioned above provide some kind of recording, but on EasyTest it self, I disliked it for another reason: A special script language. That wouldn't be that bad, if it would be a turing-complete language, but it isn't. The language is a DSL special for tests, and it's easy to read, but I never got warm with it. Most of the teams I work with don't want to learn a new language, especially on top on new concepts (yes there are a lot of people out there, want to start testing, but have no idea how).
But then I discovered an old blog post (!!) from Tolis (the creator of eXpand-framework) how to write EasyTests in C# and started to play with it. After checking out this SupportCenter article I got it working, and what should I say? I'm in love. But let's talk about a powerful functional testing pattern first: The Page-Object-Pattern.
Page-Object-Pattern
What's a page object? It's a powerful abstraction hiding the nitty gritty details of your UI out of the test and let you focus on what a user see's when he is using your UI.
It encapsulates selectors, actions and user interactions in an easy maintainable way.
This sample is pure pseudo code, no implementation detail about any of the above mentioned technologies are used. This will lay the foundation for the real world example later in this post.
Imaging a typical XAF-Winforms application. Navigation on the left with a Customer_ListView
NavigationItem
, when you click on it, the Customer_ListView
appears. It should be sorted by Name
. On DoubleClick
it should show the Customer_DetailView
with the data. Change some fields, click on SaveAndClose
and be sure that the Grid
in the ListView
is updated accordingly.
What would PageObject's
look like?
public class RootPageObject
{
public NavigationPageObject NavigateTo() => new NavigationPageObject();
}
public class NavigationPageObject
{
public CustomerListViewPageObject Customers() => new CustomerListViewPageObject();
}
public class CustomerListViewPageObject
{
public CustomerDetailViewPageObject NewRecord() => => new CustomerDetailViewPageObject();
public CustomerDetailViewPageObject OpenRecord(int rowNumber) => new CustomerDetailViewPageObject();
public string GetName(int rowNumber) => /**/;
}
public class CustomerDetailViewPageObject
{
public CustomerListViewPageObject SaveAndClose() => new CustomerListViewPageObject();
public CustomerDetailViewPageObject SetName(string name) => this;
}
If we want to write a test it could look like something like that:
[Fact]
public void NewCustomersShouldSortedByName()
{
var app = new RootPageObject();
CustomerListPageObject list = app.NavigateTo().Customers()
.NewRecord()
.SetName("Manuel")
.SaveAndClose()
.NewRecord()
.SetName("Alice")
.SaveAndClose();
list.ShouldSatisfyAllConditions(
() => list.GetName(0).ShouldBe("Alice"),
() => list.GetName(1).ShouldBe("Manuel"),
);
}
As you can see, the code is very clear. It mimics the behavior of the user. Navigate to the customers ListView
, click on New
, enter some data, SaveAndClose
, hit New
again, enter more data, check if the sorting is correct.
The test it self is easy to read, reason about and is DRY. Did you see any implementation detail? No? Me neither. And thats the goal with the Page-Object-Pattern.
The fluent object pattern here helps a lot with discoverability (intellisense). It's not necessary to apply the pattern, but it makes reading the tests a breeze (if you get code indention right ;))
Hide the UI-Details inside the page object's to abstract away possible UI changes and increase maintainability through abstraction. Focus on what a user can do with your application, not on the actual UI or technical implementation details.
Run EasyTests in Code with NUnit
Based on the SupportCenter article and the old blog post from Tolis and the nice help from the team, I got a solution working that looks like this:
This is just the port to the currently latest version (19.1.5) at the moment. TLDR: If you want to skip the technical part, go straight to my recommended version.
There are some considerations to make when writing tests in general. One of them is autonomy. To isolate potential bugs, and make test's reliable, stable and repeatable and order independent, the application should start at a predictable state. So restarting the application on every test is expensive, but totally worth it on the long run.
Let's start with a basic test organization pattern: Generic test fixture using NUnit:
using DevExpress.EasyTest.Framework;
using NUnit.Framework;
namespace EasyTest.Tests.Utils
{
[TestFixture]
public class EasyTestTestsBase<T> where T : IEasyTestFixtureHelper, new()
{
private IEasyTestFixtureHelper helper;
protected TestCommandAdapter commandAdapter => helper.CommandAdapter;
protected ICommandAdapter adapter => helper.Adapter;
[OneTimeSetUp]
public void SetupFixture()
{
helper = new T();
helper.SetupFixture();
}
[SetUp]
public void SetUp() => helper.SetUp();
[TearDown]
public void TearDown() => helper.TearDown();
[OneTimeTearDown]
public void TearDownFixture() => helper.TearDownFixture();
protected bool IsWeb => helper.IsWeb;
}
}
using System;
using DevExpress.EasyTest.Framework;
namespace EasyTest.Tests.Utils {
public interface IEasyTestFixtureHelper {
void SetupFixture();
void SetUp();
void TearDown();
void TearDownFixture();
TestCommandAdapter CommandAdapter { get; }
ICommandAdapter Adapter { get; }
bool IsWeb { get; }
}
}
I like it cause it removes the overhead of remembering the right Attributes
and makes our tests consistent.
Next let's dive into the the TestCommandAdapter
. This class is used to send commands to the XAF application and pulling out values to verify.
using DevExpress.EasyTest.Framework;
using DevExpress.EasyTest.Framework.Commands;
namespace EasyTest.Tests.Utils
{
public class TestCommandAdapter
{
private readonly ICommandAdapter adapter;
private readonly TestApplication testApplication;
public TestCommandAdapter(ICommandAdapter webAdapter, TestApplication testApplication)
{
this.testApplication = testApplication;
adapter = webAdapter;
}
internal void DoAction(string name, string paramValue)
=> new ActionCommand().DoAction(adapter, name, paramValue);
internal string GetActionValue(string name)
{
var control = adapter.CreateTestControl(TestControlType.Action, name).GetInterface<IControlText>();
return control.Text;
}
internal string GetFieldValue(string fieldName)
=> CheckFieldValuesCommand.GetFieldValue(adapter, fieldName);
internal void ProcessRecord(string tableName, string[] columnNames, string[] values, string actionName)
{
ProcessRecordCommand command = new ProcessRecordCommand();
command.SetApplicationOptions(testApplication);
command.ProcessRecord(adapter, tableName, actionName, columnNames, values);
}
internal void SetFieldValue(string fieldName, string value)
=> FillFieldCommand.SetFieldCommand(adapter, fieldName, value);
public IGridColumn GetColumn(ITestControl testControl, string columnName)
{
foreach (IGridColumn column in testControl.GetInterface<IGridBase>().Columns)
{
if (string.Compare(column.Caption, columnName, testApplication.IgnoreCase) == 0)
{
return column;
}
}
return null;
}
internal string GetCellValue(string tableName, int row, string columnName)
{
var testControl = adapter.CreateTestControl(TestControlType.Table, tableName);
var gridControl = testControl.GetInterface<IGridBase>();
return gridControl.GetCellValue(row, GetColumn(testControl, columnName));
}
internal object GetTableRowCount(string tableName)
{
var gridControl = adapter.CreateTestControl(TestControlType.Table, tableName).GetInterface<IGridBase>();
return gridControl.GetRowCount();
}
}
}
Now we need to initialize the application's for win and web:
using System.Xml;
using DevExpress.EasyTest.Framework;
namespace EasyTest.Tests.Utils
{
public abstract class TestFixtureHelperBase : IEasyTestFixtureHelper
{
public abstract TestCommandAdapter CommandAdapter { get; }
public abstract ICommandAdapter Adapter { get; }
public abstract bool IsWeb { get; }
public abstract void SetUp();
public abstract void SetupFixture();
public abstract void TearDown();
public abstract void TearDownFixture();
protected static XmlAttribute CreateAttribute(XmlDocument doc, string attributeName, string attributeValue)
{
var entry = doc.CreateAttribute(attributeName);
entry.Value = attributeValue;
return entry;
}
protected static XmlAttribute CreateAttribute(XmlDocument doc, string attributeName, bool attributeValue)
=> CreateAttribute(doc, attributeName, attributeValue.ToString());
}
}
using DevExpress.EasyTest.Framework;
using DevExpress.ExpressApp.EasyTest.WebAdapter;
using DevExpress.ExpressApp.Xpo;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Xml;
namespace EasyTest.Tests.Utils
{
public abstract class WebEasyTestFixtureHelperBase : TestFixtureHelperBase
{
private const string testWebApplicationRootUrl = "http://localhost:3057";
protected WebAdapter webAdapter;
protected TestCommandAdapter commandAdapter;
protected ICommandAdapter adapter;
protected TestApplication application;
public WebEasyTestFixtureHelperBase(string relativePathToWebApplication)
{
var testApplicationDir = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), relativePathToWebApplication);
application = new TestApplication
{
IgnoreCase = true,
};
var doc = new XmlDocument();
var additionalAttributes = new List<XmlAttribute>
{
CreateAttribute(doc, "PhysicalPath", testApplicationDir),
CreateAttribute(doc, "URL", $"{testWebApplicationRootUrl}{GetUrlOptions()}"),
CreateAttribute(doc, "SingleWebDev", true),
CreateAttribute(doc, "DontRestartIIS", true),
CreateAttribute(doc, "UseIISExpress", true),
};
application.AdditionalAttributes = additionalAttributes.ToArray();
}
protected virtual string GetUrlOptions() => "/default.aspx";
public override void SetupFixture()
{
webAdapter = new WebAdapter();
webAdapter.RunApplication(application, InMemoryDataStoreProvider.ConnectionString);
}
public override void SetUp()
{
adapter = webAdapter.CreateCommandAdapter();
commandAdapter = new TestCommandAdapter(adapter, application);
}
public override void TearDown()
{
var urlParams = GetUrlOptions();
webAdapter.WebBrowser.Navigate(testWebApplicationRootUrl + urlParams + (urlParams.Contains("?") ? "&" : "?") + "Reset=true");
}
public override void TearDownFixture()
{
webAdapter.WebBrowser.Close();
webAdapter.KillApplication(application, KillApplicationContext.TestAborted);
}
public override TestCommandAdapter CommandAdapter => commandAdapter;
public override ICommandAdapter Adapter => adapter;
public override bool IsWeb => true;
}
}
using System.Collections.Generic;
using System.IO;
using System.Xml;
using DevExpress.EasyTest.Framework;
using DevExpress.ExpressApp.EasyTest.WinAdapter;
using DevExpress.ExpressApp.Xpo;
namespace EasyTest.Tests.Utils
{
public abstract class WinEasyTestFixtureHelperBase : TestFixtureHelperBase
{
private TestApplication application;
private WinAdapter applicationAdapter;
private string applicationDirectoryName;
private string applicationName;
protected ICommandAdapter adapter;
protected TestCommandAdapter commandAdapter;
public WinEasyTestFixtureHelperBase(string applicationDirectoryName, string applicationName)
{
this.applicationDirectoryName = applicationDirectoryName;
this.applicationName = applicationName;
}
public override void SetUp()
{
applicationAdapter = new WinAdapter();
applicationAdapter.RunApplication(application, $"ConnectionString={InMemoryDataStoreProvider.ConnectionString};FOO=BAR");
adapter = ((IApplicationAdapter)applicationAdapter).CreateCommandAdapter();
commandAdapter = new TestCommandAdapter(adapter, application);
}
public override void SetupFixture()
{
application = new TestApplication();
var doc = new XmlDocument();
var additionalAttributes = new List<XmlAttribute>
{
CreateAttribute(doc, "FileName", Path.GetFullPath(Path.Combine($@"..\..\..\..\{applicationDirectoryName}", @"bin\EasyTest\net462\" + applicationName))),
CreateAttribute(doc, "CommunicationPort", "4100"),
};
application.AdditionalAttributes = additionalAttributes.ToArray();
}
public override void TearDown()
=> applicationAdapter.KillApplication(application, KillApplicationContext.TestAborted);
public override void TearDownFixture() { }
public override ICommandAdapter Adapter => adapter;
public override TestCommandAdapter CommandAdapter => commandAdapter;
public override bool IsWeb => false;
}
}
using EasyTest.Tests.Utils;
namespace EasyTest.Tests
{
public class WinTestApplicationHelper : WinEasyTestFixtureHelperBase
{
public WinTestApplicationHelper() : base("TestApplication.Win", "TestApplication.Win.exe") { }
}
public class WebTestApplicationHelper : WebEasyTestFixtureHelperBase
{
public WebTestApplicationHelper() : base(@"..\..\..\..\TestApplication.Web") { }
}
}
Puh that's a lot of code, but it's not that hard to understand. The tricky part is getting the path's right ;). Normally I would rather use environment variables instead of hard coding them, but for now that's fine. Let's have a look at the test cases them self:
using System;
using System.Collections.Generic;
using NUnit.Framework;
using DevExpress.EasyTest.Framework;
using EasyTest.Tests.Utils;
namespace EasyTest.Tests
{
public abstract class CommonTests<T> : EasyTestTestsBase<T> where T : IEasyTestFixtureHelper, new()
{
protected void ChangeContactNameTest_()
{
var control = adapter.CreateTestControl(TestControlType.Table, "");
var table = control.GetInterface<IGridBase>();
Assert.AreEqual(2, table.GetRowCount());
var column = commandAdapter.GetColumn(control, "Full Name");
Assert.AreEqual("John Nilsen", table.GetCellValue(0, column));
Assert.AreEqual("Mary Tellitson", table.GetCellValue(1, column));
commandAdapter.ProcessRecord("Contact", new string[] { "Full Name" }, new string[] { "Mary Tellitson" }, "");
Assert.AreEqual("Mary Tellitson", commandAdapter.GetFieldValue("Full Name"));
Assert.AreEqual("Development Department", commandAdapter.GetFieldValue("Department"));
Assert.AreEqual("Manager", commandAdapter.GetFieldValue("Position"));
if (IsWeb)
{
commandAdapter.DoAction("Edit", null);
}
commandAdapter.SetFieldValue("First Name", "User_1");
commandAdapter.SetFieldValue("Last Name", "User_2");
commandAdapter.SetFieldValue("Position", "Developer");
commandAdapter.DoAction("Save", null);
Assert.AreEqual("User_1 User_2", commandAdapter.GetFieldValue("Full Name"));
Assert.AreEqual("Developer", commandAdapter.GetFieldValue("Position"));
}
protected void WorkingWithTasks_()
{
commandAdapter.DoAction("Navigation", "Default.Demo Task");
commandAdapter.ProcessRecord("Demo Task", new string[] { "Subject" }, new string[] { "Fix breakfast" }, "");
var control = adapter.CreateTestControl(TestControlType.Table, "Contacts");
var table = control.GetInterface<IGridBase>();
Assert.AreEqual(0, table.GetRowCount());
commandAdapter.DoAction("Contacts.Link", null);
control = adapter.CreateTestControl(TestControlType.Table, "Contact");
control.GetInterface<IGridRowsSelection>().SelectRow(0);
commandAdapter.DoAction("OK", null);
control = adapter.CreateTestControl(TestControlType.Table, "Contacts");
table = control.GetInterface<IGridBase>();
Assert.AreEqual(1, table.GetRowCount());
Assert.AreEqual("John Nilsen", commandAdapter.GetCellValue("Contacts", 0, "Full Name"));
}
protected void ChangeContactNameAgainTest_()
{
Assert.AreEqual("John Nilsen", commandAdapter.GetCellValue("Contact", 0, "Full Name"));
Assert.AreEqual("Mary Tellitson", commandAdapter.GetCellValue("Contact", 1, "Full Name"));
commandAdapter.ProcessRecord("Contact", new string[] { "Full Name" }, new string[] { "Mary Tellitson" }, "");
if (IsWeb)
{
commandAdapter.DoAction("Edit", null);
}
Assert.AreEqual("Mary Tellitson", commandAdapter.GetFieldValue("Full Name"));
Assert.AreEqual("Development Department", commandAdapter.GetFieldValue("Department"));
commandAdapter.SetFieldValue("First Name", "User_1");
commandAdapter.SetFieldValue("Last Name", "User_2");
commandAdapter.DoAction("Save", null);
commandAdapter.DoAction("Navigation", "Contact");
Assert.AreEqual("John Nilsen", commandAdapter.GetCellValue("Contact", 0, "Full Name"));
Assert.AreEqual("User_1 User_2", commandAdapter.GetCellValue("Contact", 1, "Full Name"));
}
}
}
using System;
using NUnit.Framework;
namespace EasyTest.Tests
{
[TestFixture]
public class WinTests : CommonTests<WinTestApplicationHelper>
{
[Test]
public void ChangeContactNameTest() => ChangeContactNameTest_();
[Test]
public void WorkingWithTasks() => WorkingWithTasks_();
[Test]
public void ChangeContactNameAgainTest()
=> ChangeContactNameAgainTest_();
}
}
using NUnit.Framework;
using DevExpress.EasyTest.Framework;
namespace EasyTest.Tests
{
[TestFixture]
public class WebTests : CommonTests<WebTestApplicationHelper>
{
[Test]
public void ChangeContactNameTest() => ChangeContactNameTest_();
[Test]
public void WorkingWithTasks() => WorkingWithTasks_();
[Test]
public void ChangeContactNameAgainTest()
=> ChangeContactNameAgainTest_();
[Test]
public void UnlinkActionTest()
{
commandAdapter.DoAction("Navigation", "Department");
commandAdapter.ProcessRecord("Department", new string[] { "Title" }, new string[] { "Development Department" }, "");
commandAdapter.DoAction("Positions", null);
var gridControl = adapter.CreateTestControl(TestControlType.Table, "Positions");
Assert.AreEqual(2, gridControl.GetInterface<IGridBase>().GetRowCount());
Assert.AreEqual("Developer", commandAdapter.GetCellValue("Positions", 0, "Title"));
var unlink = adapter.CreateTestControl(TestControlType.Action, "Positions.Unlink");
Assert.IsFalse(unlink.GetInterface<IControlEnabled>().Enabled);
gridControl.GetInterface<IGridRowsSelection>().SelectRow(0);
Assert.IsTrue(unlink.GetInterface<IControlEnabled>().Enabled);
commandAdapter.DoAction("Positions.Unlink", null);
Assert.AreEqual(1, gridControl.GetInterface<IGridBase>().GetRowCount());
Assert.AreEqual("Manager", commandAdapter.GetCellValue("Positions", 0, "Title"));
commandAdapter.DoAction("Contacts", null);
unlink = adapter.CreateTestControl(TestControlType.Action, "Contacts.Unlink");
Assert.IsFalse(unlink.GetInterface<IControlEnabled>().Enabled);
}
}
}
To run the web tests VisualStudio need's to be run as an Administrator.
Okay, that's not that bad! We are using the generic test fixture to keep test's consistent and we can reuse test cases for win and web and web, as well as writing platform specific ones! But where is the test data coming from and how are we isolating the data between the test cases? Let's have a look into one very special class used by our applications: The InMemoryDataStoreProvider
:
using System;
using DevExpress.Xpo.DB;
namespace TestApplication.EasyTest
{
public class InMemoryDataStoreProvider : InMemoryDataStore
{
new public const string XpoProviderTypeString = "InMemoryDataSet";
static InMemoryDataStoreProvider() => Register();
new public static void Register()
=> RegisterDataStoreProvider(XpoProviderTypeString, CreateProviderFromString);
private static object syncRoot = new object();
private static InMemoryDataStore _savedDataSet;
private static InMemoryDataStore savedDataSet
{
get => _savedDataSet;
set
{
lock (syncRoot)
{
_savedDataSet = value;
}
}
}
private static InMemoryDataStore _store;
private static InMemoryDataStore store
{
get => _store;
set
{
lock (syncRoot)
{
_store = value;
}
}
}
new public static IDataStore CreateProviderFromString(string connectionString, AutoCreateOption autoCreateOption, out IDisposable[] objectsToDisposeOnDisconnect)
{
if (store == null)
{
store = new InMemoryDataStore(AutoCreateOption.DatabaseAndSchema);
}
objectsToDisposeOnDisconnect = new IDisposable[] { };
return store;
}
public static bool HasData => savedDataSet != null;
public static void Save()
{
if (!HasData && store != null)
{
savedDataSet = store;
}
}
public static void Reload()
{
if (HasData && store != null)
{
store.ReadFromInMemoryDataStore(savedDataSet);
}
}
}
}
This class allows us to save a snapshot of the data at any time, as well as reloading the snapshot! That's super useful cause we now can control the state of the database between test cases.
WinApplication.cs
:
using System;
using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Win;
using DevExpress.ExpressApp.Xpo;
namespace TestApplication.Win
{
public partial class TestApplicationWindowsFormsApplication : WinApplication
{
public TestApplicationWindowsFormsApplication()
{
InitializeComponent();
DelayedViewItemsInitialization = true;
#if EASYTEST
DatabaseUpdateMode = DatabaseUpdateMode.UpdateDatabaseAlways;
CheckCompatibilityType = CheckCompatibilityType.ModuleInfo;
#endif
}
protected override void CreateDefaultObjectSpaceProvider(CreateCustomObjectSpaceProviderEventArgs args)
{
args.ObjectSpaceProvider = new XPObjectSpaceProvider(new ConnectionStringDataStoreProvider(args.ConnectionString), false);
}
private void TestApplicationWindowsFormsApplication_DatabaseVersionMismatch(object sender, DevExpress.ExpressApp.DatabaseVersionMismatchEventArgs e)
{
#if EASYTEST
e.Updater.Update();
e.Handled = true;
TestApplication.EasyTest.InMemoryDataStoreProvider.Save();
#endif
}
}
}
using System;
using System.Configuration;
using System.Windows.Forms;
using DevExpress.ExpressApp.Security;
namespace TestApplication.Win
{
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main(params string[] args)
{
#if EASYTEST
DevExpress.ExpressApp.Win.EasyTest.EasyTestRemotingRegistration.Register();
TestApplication.EasyTest.InMemoryDataStoreProvider.Register();
#endif
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
EditModelPermission.AlwaysGranted = System.Diagnostics.Debugger.IsAttached;
var winApplication = new TestApplicationWindowsFormsApplication();
#if EASYTEST
winApplication.ConnectionString = $"XpoProvider={TestApplication.EasyTest.InMemoryDataStoreProvider.XpoProviderTypeString}";
#endif
if (ConfigurationManager.ConnectionStrings["ConnectionString"] != null)
{
winApplication.ConnectionString = ConfigurationManager.ConnectionStrings["ConnectionString"].ConnectionString;
}
try
{
winApplication.Setup();
winApplication.Start();
}
catch (Exception e)
{
winApplication.HandleException(e);
}
}
}
}
You can see we are saving the database state right after the database updater. So it's based on our ModuleUpdater
:
ModuleUpdater.cs
:
using System;
using DevExpress.Data.Filtering;
using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Updating;
namespace TestApplication.Module
{
public class Updater : ModuleUpdater
{
public Updater(IObjectSpace objectSpace, Version currentDBVersion) : base(objectSpace, currentDBVersion) { }
public override void UpdateDatabaseAfterUpdateSchema()
{
base.UpdateDatabaseAfterUpdateSchema();
#if EASYTEST
var developerPosition = ObjectSpace.FindObject<Position>(CriteriaOperator.Parse("Title == 'Developer'"));
if (developerPosition == null)
{
developerPosition = ObjectSpace.CreateObject<Position>();
developerPosition.Title = "Developer";
developerPosition.Save();
}
var managerPosition = ObjectSpace.FindObject<Position>(CriteriaOperator.Parse("Title == 'Manager'"));
if (managerPosition == null)
{
managerPosition = ObjectSpace.CreateObject<Position>();
managerPosition.Title = "Manager";
managerPosition.Save();
}
var devDepartment = ObjectSpace.FindObject<Department>(CriteriaOperator.Parse("Title == 'Development Department'"));
if (devDepartment == null)
{
devDepartment = ObjectSpace.CreateObject<Department>();
devDepartment.Title = "Development Department";
devDepartment.Office = "205";
devDepartment.Positions.Add(developerPosition);
devDepartment.Positions.Add(managerPosition);
devDepartment.Save();
}
var contactMary = ObjectSpace.FindObject<Contact>(CriteriaOperator.Parse("FirstName == 'Mary' && LastName == 'Tellitson'"));
if (contactMary == null)
{
contactMary = ObjectSpace.CreateObject<Contact>();
contactMary.FirstName = "Mary";
contactMary.LastName = "Tellitson";
contactMary.Email = "mary_tellitson@md.com";
contactMary.Birthday = new DateTime(1980, 11, 27);
contactMary.Department = devDepartment;
contactMary.Position = managerPosition;
contactMary.Save();
}
var contactJohn = ObjectSpace.FindObject<Contact>(CriteriaOperator.Parse("FirstName == 'John' && LastName == 'Nilsen'"));
if (contactJohn == null)
{
contactJohn = ObjectSpace.CreateObject<Contact>();
contactJohn.FirstName = "John";
contactJohn.LastName = "Nilsen";
contactJohn.Email = "john_nilsen@md.com";
contactJohn.Birthday = new DateTime(1981, 10, 3);
contactJohn.Department = devDepartment;
contactJohn.Position = developerPosition;
contactJohn.Save();
}
if (ObjectSpace.FindObject<DemoTask>(CriteriaOperator.Parse("Subject == 'Review reports'")) == null)
{
var task = ObjectSpace.CreateObject<DemoTask>();
task.Subject = "Review reports";
task.AssignedTo = contactJohn;
task.StartDate = DateTime.Parse("May 03, 2008");
task.DueDate = DateTime.Parse("September 06, 2008");
task.Status = DevExpress.Persistent.Base.General.TaskStatus.InProgress;
task.Priority = Priority.High;
task.EstimatedWork = 60;
task.Description = "Analyse the reports and assign new tasks to employees.";
task.Save();
}
if (ObjectSpace.FindObject<DemoTask>(CriteriaOperator.Parse("Subject == 'Fix breakfast'")) == null)
{
var task = ObjectSpace.CreateObject<DemoTask>();
task.Subject = "Fix breakfast";
task.AssignedTo = contactMary;
task.StartDate = DateTime.Parse("May 03, 2008");
task.DueDate = DateTime.Parse("May 04, 2008");
task.Status = DevExpress.Persistent.Base.General.TaskStatus.Completed;
task.Priority = Priority.Low;
task.EstimatedWork = 1;
task.ActualWork = 3;
task.Description = "The Development Department - by 9 a.m.\r\nThe R&QA Department - by 10 a.m.";
task.Save();
}
if (ObjectSpace.FindObject<DemoTask>(CriteriaOperator.Parse("Subject == 'Task1'")) == null)
{
var task = ObjectSpace.CreateObject<DemoTask>();
task.Subject = "Task1";
task.AssignedTo = contactJohn;
task.StartDate = DateTime.Parse("June 03, 2008");
task.DueDate = DateTime.Parse("June 06, 2008");
task.Status = DevExpress.Persistent.Base.General.TaskStatus.Completed;
task.Priority = Priority.High;
task.EstimatedWork = 10;
task.ActualWork = 15;
task.Description = "A task designed specially to demonstrate the PivotChart module. Switch to the Reports navigation group to view the generated analysis.";
task.Save();
}
if (ObjectSpace.FindObject<DemoTask>(CriteriaOperator.Parse("Subject == 'Task2'")) == null)
{
var task = ObjectSpace.CreateObject<DemoTask>();
task.Subject = "Task2";
task.AssignedTo = contactJohn;
task.StartDate = DateTime.Parse("July 03, 2008");
task.DueDate = DateTime.Parse("July 06, 2008");
task.Status = DevExpress.Persistent.Base.General.TaskStatus.Completed;
task.Priority = Priority.Low;
task.EstimatedWork = 8;
task.ActualWork = 16;
task.Description = "A task designed specially to demonstrate the PivotChart module. Switch to the Reports navigation group to view the generated analysis.";
task.Save();
}
ObjectSpace.CommitChanges();
#endif
}
}
}
The web version is a little bit more complicated because of the nature of IIS and ASPX applications:
Global.asax.cs
:
using System;
using System.Configuration;
using System.Web;
using System.Web.Routing;
using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Security;
using DevExpress.ExpressApp.Web;
using DevExpress.Persistent.Base;
using DevExpress.Web;
namespace TestApplication.Web
{
public class Global : System.Web.HttpApplication
{
#if EASYTEST
protected void Application_AcquireRequestState(Object sender, EventArgs e)
{
if (HttpContext.Current.Request.Params["Reset"] == "true")
{
TestApplication.EasyTest.InMemoryDataStoreProvider.Reload();
WebApplication.Instance.LogOff();
WebApplication.Redirect(Request.RawUrl.Replace("&Reset=true", "").Replace("?Reset=true", ""), true);
}
}
#endif
protected void Application_Start(Object sender, EventArgs e)
{
RouteTable.Routes.RegisterXafRoutes();
ASPxWebControl.CallbackError += new EventHandler(Application_Error);
#if EASYTEST
DevExpress.ExpressApp.Web.TestScripts.TestScriptsManager.EasyTestEnabled = true;
ConfirmationsHelper.IsConfirmationsEnabled = false;
TestApplication.EasyTest.InMemoryDataStoreProvider.Register();
#endif
}
protected void Session_Start(Object sender, EventArgs e)
{
Tracing.Initialize();
#if EASYTEST
TestApplication.EasyTest.InMemoryDataStoreProvider.Reload();
#endif
var application = new TestApplicationAspNetApplication();
WebApplication.SetInstance(Session, application);
SecurityStrategy security = (SecurityStrategy)WebApplication.Instance.Security;
security.RegisterXPOAdapterProviders();
DevExpress.ExpressApp.Web.Templates.DefaultVerticalTemplateContentNew.ClearSizeLimit();
WebApplication.Instance.SwitchToNewStyle();
if (ConfigurationManager.ConnectionStrings["ConnectionString"] != null)
{
WebApplication.Instance.ConnectionString = ConfigurationManager.ConnectionStrings["ConnectionString"].ConnectionString;
}
#if EASYTEST
TestApplication.EasyTest.InMemoryDataStoreProvider.Reload();
if (ConfigurationManager.ConnectionStrings["EasyTestConnectionString"] != null)
{
WebApplication.Instance.ConnectionString = ConfigurationManager.ConnectionStrings["EasyTestConnectionString"].ConnectionString;
}
#endif
#if EASYTEST
WebApplication.Instance.ConnectionString = $"XpoProvider={TestApplication.EasyTest.InMemoryDataStoreProvider.XpoProviderTypeString}";
#endif
#if DEBUG
if (System.Diagnostics.Debugger.IsAttached && WebApplication.Instance.CheckCompatibilityType == CheckCompatibilityType.DatabaseSchema)
{
WebApplication.Instance.DatabaseUpdateMode = DatabaseUpdateMode.UpdateDatabaseAlways;
}
#endif
WebApplication.Instance.Setup();
WebApplication.Instance.Start();
}
}
}
WebApplication.cs
:
using System;
using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Web;
using DevExpress.ExpressApp.Xpo;
namespace TestApplication.Web
{
// For more typical usage scenarios, be sure to check out https://docs.devexpress.com/eXpressAppFramework/DevExpress.ExpressApp.Web.WebApplication
public partial class TestApplicationAspNetApplication : WebApplication
{
protected override void CreateDefaultObjectSpaceProvider(CreateCustomObjectSpaceProviderEventArgs args)
{
args.ObjectSpaceProvider = new XPObjectSpaceProvider(GetDataStoreProvider(args.ConnectionString, args.Connection), true);
args.ObjectSpaceProviders.Add(new NonPersistentObjectSpaceProvider(TypesInfo, null));
}
private IXpoDataStoreProvider GetDataStoreProvider(string connectionString, System.Data.IDbConnection connection)
{
System.Web.HttpApplicationState application = (System.Web.HttpContext.Current != null) ? System.Web.HttpContext.Current.Application : null;
IXpoDataStoreProvider dataStoreProvider = null;
if (application != null && application["DataStoreProvider"] != null)
{
dataStoreProvider = application["DataStoreProvider"] as IXpoDataStoreProvider;
}
else
{
dataStoreProvider = XPObjectSpaceProvider.GetDataStoreProvider(connectionString, connection, true);
if (application != null)
{
application["DataStoreProvider"] = dataStoreProvider;
}
}
return dataStoreProvider;
}
private void Solution1AspNetApplication_DatabaseVersionMismatch(object sender, DevExpress.ExpressApp.DatabaseVersionMismatchEventArgs e)
{
#if EASYTEST
e.Updater.Update();
e.Handled = true;
TestApplication.EasyTest.InMemoryDataStoreProvider.Save();
#endif
}
}
}
Here you can see we can reset the application state with the Application_AcquireRequestState
method. That means if we navigate to http://localhost:3057?Reset=true
we can reload the database and we also reload the state after the session has started.
You can find the full source code on github
Okay now we have the basic's! But I don't like how the tests them self are structured, it's really hard to get what the application is supposed to do. Let's refactor the code to use xUnit and the page object pattern to make it more readable!
Run EasyTests in Code with XUnit
The port is rather straight forward so let's zap through the code real quick:
using System;
using System.Xml;
using DevExpress.EasyTest.Framework;
namespace EasyTest.Tests.Utils
{
public abstract class EasyTestFixtureBase : IDisposable
{
public abstract TestCommandAdapter CommandAdapter { get; }
public abstract ICommandAdapter Adapter { get; }
public abstract bool IsWeb { get; }
public abstract void Dispose();
protected static XmlAttribute CreateAttribute(XmlDocument doc, string attributeName, string attributeValue)
{
var entry = doc.CreateAttribute(attributeName);
entry.Value = attributeValue;
return entry;
}
protected static XmlAttribute CreateAttribute(XmlDocument doc, string attributeName, bool attributeValue)
=> CreateAttribute(doc, attributeName, attributeValue.ToString());
}
}
using DevExpress.EasyTest.Framework;
using DevExpress.EasyTest.Framework.Commands;
namespace EasyTest.Tests.Utils
{
public class TestCommandAdapter
{
private readonly ICommandAdapter adapter;
private readonly TestApplication testApplication;
public TestCommandAdapter(ICommandAdapter webAdapter, TestApplication testApplication)
{
this.testApplication = testApplication;
adapter = webAdapter;
}
internal void DoAction(string name, string paramValue)
=> new ActionCommand().DoAction(adapter, name, paramValue);
internal string GetActionValue(string name)
{
var control = adapter.CreateTestControl(TestControlType.Action, name).GetInterface<IControlText>();
return control.Text;
}
internal string GetFieldValue(string fieldName)
=> CheckFieldValuesCommand.GetFieldValue(adapter, fieldName);
internal void ProcessRecord(string tableName, string[] columnNames, string[] values, string actionName)
{
ProcessRecordCommand command = new ProcessRecordCommand();
command.SetApplicationOptions(testApplication);
command.ProcessRecord(adapter, tableName, actionName, columnNames, values);
}
internal void SetFieldValue(string fieldName, string value)
=> FillFieldCommand.SetFieldCommand(adapter, fieldName, value);
public IGridColumn GetColumn(ITestControl testControl, string columnName)
{
foreach (IGridColumn column in testControl.GetInterface<IGridBase>().Columns)
{
if (string.Compare(column.Caption, columnName, testApplication.IgnoreCase) == 0)
{
return column;
}
}
return null;
}
internal string GetCellValue(string tableName, int row, string columnName)
{
var testControl = adapter.CreateTestControl(TestControlType.Table, tableName);
var gridControl = testControl.GetInterface<IGridBase>();
return gridControl.GetCellValue(row, GetColumn(testControl, columnName));
}
internal object GetTableRowCount(string tableName)
{
var gridControl = adapter.CreateTestControl(TestControlType.Table, tableName).GetInterface<IGridBase>();
return gridControl.GetRowCount();
}
}
}
using DevExpress.EasyTest.Framework;
using DevExpress.ExpressApp.EasyTest.WebAdapter;
using DevExpress.ExpressApp.Xpo;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Xml;
namespace EasyTest.Tests.Utils
{
public abstract class WebEasyTestFixtureHelperBase : EasyTestFixtureBase
{
private const string testWebApplicationRootUrl = "http://localhost:3057";
protected WebAdapter webAdapter;
protected TestCommandAdapter commandAdapter;
protected ICommandAdapter adapter;
protected TestApplication application;
public WebEasyTestFixtureHelperBase(string relativePathToWebApplication)
{
var testApplicationDir = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), relativePathToWebApplication);
application = new TestApplication
{
IgnoreCase = true,
};
var doc = new XmlDocument();
var additionalAttributes = new List<XmlAttribute>
{
CreateAttribute(doc, "PhysicalPath", testApplicationDir),
CreateAttribute(doc, "URL", $"{testWebApplicationRootUrl}{GetUrlOptions()}"),
CreateAttribute(doc, "SingleWebDev", true),
CreateAttribute(doc, "DontRestartIIS", true),
CreateAttribute(doc, "UseIISExpress", true),
};
application.AdditionalAttributes = additionalAttributes.ToArray();
webAdapter = new WebAdapter();
webAdapter.RunApplication(application, InMemoryDataStoreProvider.ConnectionString);
adapter = webAdapter.CreateCommandAdapter();
commandAdapter = new TestCommandAdapter(adapter, application);
}
protected virtual string GetUrlOptions() => "/default.aspx";
public override void Dispose()
{
var urlParams = GetUrlOptions();
webAdapter.WebBrowser.Navigate(testWebApplicationRootUrl + urlParams + (urlParams.Contains("?") ? "&" : "?") + "Reset=true");
webAdapter.WebBrowser.Close();
try
{
webAdapter.KillApplication(application, KillApplicationContext.TestNormalEnded);
}
catch { }
}
public override TestCommandAdapter CommandAdapter => commandAdapter;
public override ICommandAdapter Adapter => adapter;
public override bool IsWeb => true;
}
}
using System.Collections.Generic;
using System.IO;
using System.Xml;
using DevExpress.EasyTest.Framework;
using DevExpress.ExpressApp.EasyTest.WinAdapter;
using DevExpress.ExpressApp.Xpo;
namespace EasyTest.Tests.Utils
{
public abstract class WinEasyTestFixtureHelperBase : EasyTestFixtureBase
{
private TestApplication application;
private WinAdapter applicationAdapter;
private string applicationDirectoryName;
private string applicationName;
protected ICommandAdapter adapter;
protected TestCommandAdapter commandAdapter;
public WinEasyTestFixtureHelperBase(string applicationDirectoryName, string applicationName)
{
this.applicationDirectoryName = applicationDirectoryName;
this.applicationName = applicationName;
application = new TestApplication();
var doc = new XmlDocument();
var additionalAttributes = new List<XmlAttribute>
{
CreateAttribute(doc, "FileName", Path.GetFullPath(Path.Combine($@"..\..\..\..\{applicationDirectoryName}", @"bin\EasyTest\net462\" + applicationName))),
CreateAttribute(doc, "CommunicationPort", "4100"),
};
application.AdditionalAttributes = additionalAttributes.ToArray();
applicationAdapter = new WinAdapter();
applicationAdapter.RunApplication(application, $"ConnectionString={InMemoryDataStoreProvider.ConnectionString};FOO=BAR");
adapter = ((IApplicationAdapter)applicationAdapter).CreateCommandAdapter();
commandAdapter = new TestCommandAdapter(adapter, application);
}
public override void Dispose()
=> applicationAdapter.KillApplication(application, KillApplicationContext.TestAborted);
public override ICommandAdapter Adapter => adapter;
public override TestCommandAdapter CommandAdapter => commandAdapter;
public override bool IsWeb => false;
}
}
So not much changed. Only some attributes, we got rid of some additional helper classes. The test code is almost the same:
using EasyTest.Tests.Utils;
namespace EasyTest.Tests
{
public class WinTestApplicationHelper : WinEasyTestFixtureHelperBase
{
public WinTestApplicationHelper() : base("TestApplication.Win", "TestApplication.Win.exe") { }
}
public class WebTestApplicationHelper : WebEasyTestFixtureHelperBase
{
public WebTestApplicationHelper() : base(@"..\..\..\..\TestApplication.Web") { }
}
}
using System;
using System.Collections.Generic;
using NUnit.Framework;
using DevExpress.EasyTest.Framework;
using EasyTest.Tests.Utils;
using Xunit;
namespace EasyTest.Tests
{
public abstract class CommonTests<T> : IDisposable where T : EasyTestFixtureBase, new()
{
protected T Fixture { get; }
public CommonTests()
=> Fixture = new T();
public void Dispose()
=> Fixture.Dispose();
protected void ChangeContactNameTest_()
{
var control = Fixture.Adapter.CreateTestControl(TestControlType.Table, "");
var table = control.GetInterface<IGridBase>();
Assert.Equal(2, table.GetRowCount());
var column = Fixture.CommandAdapter.GetColumn(control, "Full Name");
Assert.Equal("John Nilsen", table.GetCellValue(0, column));
Assert.Equal("Mary Tellitson", table.GetCellValue(1, column));
Fixture.CommandAdapter.ProcessRecord("Contact", new string[] { "Full Name" }, new string[] { "Mary Tellitson" }, "");
Assert.Equal("Mary Tellitson", Fixture.CommandAdapter.GetFieldValue("Full Name"));
Assert.Equal("Development Department", Fixture.CommandAdapter.GetFieldValue("Department"));
Assert.Equal("Manager", Fixture.CommandAdapter.GetFieldValue("Position"));
if (Fixture.IsWeb)
{
Fixture.CommandAdapter.DoAction("Edit", null);
}
Fixture.CommandAdapter.SetFieldValue("First Name", "User_1");
Fixture.CommandAdapter.SetFieldValue("Last Name", "User_2");
Fixture.CommandAdapter.SetFieldValue("Position", "Developer");
Fixture.CommandAdapter.DoAction("Save", null);
Assert.Equal("User_1 User_2", Fixture.CommandAdapter.GetFieldValue("Full Name"));
Assert.Equal("Developer", Fixture.CommandAdapter.GetFieldValue("Position"));
}
protected void WorkingWithTasks_()
{
Fixture.CommandAdapter.DoAction("Navigation", "Default.Demo Task");
Fixture.CommandAdapter.ProcessRecord("Demo Task", new string[] { "Subject" }, new string[] { "Fix breakfast" }, "");
var control = Fixture.Adapter.CreateTestControl(TestControlType.Table, "Contacts");
var table = control.GetInterface<IGridBase>();
Assert.Equal(0, table.GetRowCount());
Fixture.CommandAdapter.DoAction("Contacts.Link", null);
control = Fixture.Adapter.CreateTestControl(TestControlType.Table, "Contact");
control.GetInterface<IGridRowsSelection>().SelectRow(0);
Fixture.CommandAdapter.DoAction("OK", null);
control = Fixture.Adapter.CreateTestControl(TestControlType.Table, "Contacts");
table = control.GetInterface<IGridBase>();
Assert.Equal(1, table.GetRowCount());
Assert.Equal("John Nilsen", Fixture.CommandAdapter.GetCellValue("Contacts", 0, "Full Name"));
}
protected void ChangeContactNameAgainTest_()
{
Assert.Equal("John Nilsen", Fixture.CommandAdapter.GetCellValue("Contact", 0, "Full Name"));
Assert.Equal("Mary Tellitson", Fixture.CommandAdapter.GetCellValue("Contact", 1, "Full Name"));
Fixture.CommandAdapter.ProcessRecord("Contact", new string[] { "Full Name" }, new string[] { "Mary Tellitson" }, "");
if (Fixture.IsWeb)
{
Fixture.CommandAdapter.DoAction("Edit", null);
}
Assert.Equal("Mary Tellitson", Fixture.CommandAdapter.GetFieldValue("Full Name"));
Assert.Equal("Development Department", Fixture.CommandAdapter.GetFieldValue("Department"));
Fixture.CommandAdapter.SetFieldValue("First Name", "User_1");
Fixture.CommandAdapter.SetFieldValue("Last Name", "User_2");
Fixture.CommandAdapter.DoAction("Save", null);
Fixture.CommandAdapter.DoAction("Navigation", "Contact");
Assert.Equal("John Nilsen", Fixture.CommandAdapter.GetCellValue("Contact", 0, "Full Name"));
Assert.Equal("User_1 User_2", Fixture.CommandAdapter.GetCellValue("Contact", 1, "Full Name"));
}
}
}
using System;
using Xunit;
namespace EasyTest.Tests
{
public class WinTests : CommonTests<WinTestApplicationHelper>
{
[Fact]
public void ChangeContactNameTest() => ChangeContactNameTest_();
[Fact]
public void WorkingWithTasks() => WorkingWithTasks_();
[Fact]
public void ChangeContactNameAgainTest()
=> ChangeContactNameAgainTest_();
}
}
using NUnit.Framework;
using DevExpress.EasyTest.Framework;
using Xunit;
namespace EasyTest.Tests
{
public class WebTests : CommonTests<WebTestApplicationHelper>
{
[Fact]
public void ChangeContactNameTest() => ChangeContactNameTest_();
[Fact]
public void WorkingWithTasks() => WorkingWithTasks_();
[Fact]
public void ChangeContactNameAgainTest()
=> ChangeContactNameAgainTest_();
[Fact]
public void UnlinkActionTest()
{
Fixture.CommandAdapter.DoAction("Navigation", "Department");
Fixture.CommandAdapter.ProcessRecord("Department", new string[] { "Title" }, new string[] { "Development Department" }, "");
Fixture.CommandAdapter.DoAction("Positions", null);
var gridControl = Fixture.Adapter.CreateTestControl(TestControlType.Table, "Positions");
Assert.Equal(2, gridControl.GetInterface<IGridBase>().GetRowCount());
Assert.Equal("Developer", Fixture.CommandAdapter.GetCellValue("Positions", 0, "Title"));
var unlink = Fixture.Adapter.CreateTestControl(TestControlType.Action, "Positions.Unlink");
Assert.False(unlink.GetInterface<IControlEnabled>().Enabled);
gridControl.GetInterface<IGridRowsSelection>().SelectRow(0);
Assert.True(unlink.GetInterface<IControlEnabled>().Enabled);
Fixture.CommandAdapter.DoAction("Positions.Unlink", null);
Assert.Equal(1, gridControl.GetInterface<IGridBase>().GetRowCount());
Assert.Equal("Manager", Fixture.CommandAdapter.GetCellValue("Positions", 0, "Title"));
Fixture.CommandAdapter.DoAction("Contacts", null);
unlink = Fixture.Adapter.CreateTestControl(TestControlType.Action, "Contacts.Unlink");
Assert.False(unlink.GetInterface<IControlEnabled>().Enabled);
}
}
}
Again nothing new. Only changed the assertions and used the Fixture
. Now let's look into the page-object-pattern:
You can find the full source code on github
Apply the Page-Object-Pattern
Let's have a look into one test case first to see if that pattern is worth anything:
using System.Collections.Generic;
using EasyTest.Tests.PageObjects;
using Shouldly;
using Xunit;
namespace EasyTest.Tests
{
public class WebTests : CommonTests<WebTestApplicationHelper>
{
[Fact]
public void UnlinkActionTest()
{
var departmentDetail = new ApplicationPageObject(Fixture)
.NavigateToDepartment()
.OpenRecordByTitle("Development Department");
departmentDetail
.Positions()
.Assert(p =>
{
p.RowCount.ShouldBe(2);
p.GetValues(0, "Title").ShouldBe(new Dictionary<string, string>
{
["Title"] = "Developer"
});
p.UnlinkAction.Enabled.ShouldBeFalse();
})
.SelectRow(0)
.Assert(p => p.UnlinkAction.Enabled.ShouldBeTrue())
.ExecuteAction(p => p.UnlinkAction)
.Assert(p =>
{
p.RowCount.ShouldBe(1);
p.GetValues(0, "Title")
.ShouldBe(new Dictionary<string, string>
{
["Title"] = "Manager"
});
});
departmentDetail
.Contacts()
.Assert(c => c.UnlinkAction.Enabled.ShouldBeFalse())
.SelectRow(0)
.Assert(c => c.UnlinkAction.Enabled.ShouldBeTrue());
}
}
}
I don't know much, but that looks like a nice structured test. It is totally clear what the test is supposed to do. It is structured in a readable fashion, not too much technical details and is focused on the user. If I'm a user of my application thats exactly the way I would interact with the application.
For the sake of completeness here is the complete test code:
using System;
using System.Collections.Generic;
using EasyTest.Tests.PageObjects;
using EasyTest.Tests.Utils;
using Shouldly;
namespace EasyTest.Tests
{
public abstract class CommonTests<T> : IDisposable where T : EasyTestFixtureBase, new()
{
protected T Fixture { get; }
public CommonTests()
=> Fixture = new T();
public void Dispose()
=> Fixture.Dispose();
protected void ChangeContactNameTest_()
{
var contactList = new ApplicationPageObject(Fixture)
.NavigateToContact()
.Assert(d =>
{
d.RowCount.ShouldBe(2);
d.GetValues(0, "Full Name")
.ShouldBe(new Dictionary<string, string>()
{
["Full Name"] = "John Nilsen"
});
d.GetValues(1, "Full Name")
.ShouldBe(new Dictionary<string, string>()
{
["Full Name"] = "Mary Tellitson"
});
});
var contactDetail = contactList
.OpenRecordByFullName("Mary Tellitson")
.Assert(c =>
{
c.FullName.ShouldBe("Mary Tellitson");
c.Department.ShouldBe("Development Department");
c.Position.ShouldBe("Manager");
})
.ExecuteActionIf(f => f.IsWeb, c => c.EditAction)
.Do(c =>
{
c.FirstName = "User_1";
c.LastName = "User_2";
c.Position = "Developer";
})
.ExecuteAction(c => c.SaveAction)
.Assert(c =>
{
c.FullName.ShouldBe("User_1 User_2");
c.Position.ShouldBe("Developer");
})
.ExecuteAction(c => c.SaveAndCloseAction);
contactList.GetValues(1, "Full Name", "Position").ShouldBe(new Dictionary<string, string>
{
["Full Name"] = "User_1 User_2",
["Position"] = "Developer"
});
}
protected void WorkingWithTasks_()
{
var taskDetail = new ApplicationPageObject(Fixture)
.NavigateTo("Demo Task")
.OpenRecord("Subject", "Fix breakfast");
taskDetail
.List("Contacts")
.Assert(c => c.RowCount.ShouldBe(0))
.ExecuteAction(c => c.LinkAction, f => new ListPageObject(f, "Contact"), contactsPopup =>
{
contactsPopup
.SelectRow(0)
.ExecuteAction(x => x.Action("OK"));
})
.Assert(c =>
{
c.RowCount.ShouldBe(1);
c.GetValues(0, "Full Name").ShouldBe(new Dictionary<string, string>
{
["Full Name"] = "John Nilsen"
});
});
}
protected void ChangeContactNameAgainTest_()
{
var application = new ApplicationPageObject(Fixture);
var contactList = application
.NavigateToContact()
.Assert(c =>
{
c.GetValues(0, "Full Name").ShouldBe(new Dictionary<string, string>
{
["Full Name"] = "John Nilsen"
});
c.GetValues(1, "Full Name").ShouldBe(new Dictionary<string, string>
{
["Full Name"] = "Mary Tellitson"
});
});
var contactDetail = contactList
.OpenRecordByFullName("Mary Tellitson")
.ExecuteActionIf(f => f.IsWeb, c => c.EditAction)
.Assert(c =>
{
c.FullName.ShouldBe("Mary Tellitson");
c.Department.ShouldBe("Development Department");
})
.Do(c =>
{
c.FirstName = "User_1";
c.LastName = "User_2";
})
.ExecuteAction(c => c.SaveAction);
application
.NavigateToContact()
.Assert(c =>
{
c.GetValues(0, "Full Name").ShouldBe(new Dictionary<string, string>
{
["Full Name"] = "John Nilsen"
});
c.GetValues(1, "Full Name").ShouldBe(new Dictionary<string, string>
{
["Full Name"] = "User_1 User_2"
});
});
}
}
}
using System.Collections.Generic;
using EasyTest.Tests.PageObjects;
using Shouldly;
using Xunit;
namespace EasyTest.Tests
{
public class WebTests : CommonTests<WebTestApplicationHelper>
{
[Fact]
public void ChangeContactNameTest() => ChangeContactNameTest_();
[Fact]
public void WorkingWithTasks() => WorkingWithTasks_();
[Fact]
public void ChangeContactNameAgainTest()
=> ChangeContactNameAgainTest_();
[Fact]
public void UnlinkActionTest()
{
var departmentDetail = new ApplicationPageObject(Fixture)
.NavigateToDepartment()
.OpenRecordByTitle("Development Department");
departmentDetail
.Positions()
.Assert(p =>
{
p.RowCount.ShouldBe(2);
p.GetValues(0, "Title").ShouldBe(new Dictionary<string, string>
{
["Title"] = "Developer"
});
p.UnlinkAction.Enabled.ShouldBeFalse();
})
.SelectRow(0)
.Assert(p => p.UnlinkAction.Enabled.ShouldBeTrue())
.ExecuteAction(p => p.UnlinkAction)
.Assert(p =>
{
p.RowCount.ShouldBe(1);
p.GetValues(0, "Title").ShouldBe(new Dictionary<string, string>
{
["Title"] = "Manager"
});
});
departmentDetail
.Contacts()
.Assert(c => c.UnlinkAction.Enabled.ShouldBeFalse())
.SelectRow(0)
.Assert(c => c.UnlinkAction.Enabled.ShouldBeTrue());
}
}
}
using System;
using Xunit;
namespace EasyTest.Tests
{
public class WinTests : CommonTests<WinTestApplicationHelper>
{
[Fact]
public void ChangeContactNameTest() => ChangeContactNameTest_();
[Fact]
public void WorkingWithTasks() => WorkingWithTasks_();
[Fact]
public void ChangeContactNameAgainTest()
=> ChangeContactNameAgainTest_();
}
}
So now what? Let's look at the PageObjects. Don't be scared, we use recursive generics again as in the last post.
using DevExpress.EasyTest.Framework;
using EasyTest.Tests.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace EasyTest.Tests.PageObjects
{
public abstract class PageObject<T> where T : PageObject<T>
{
protected readonly EasyTestFixtureBase Fixture;
public PageObject(EasyTestFixtureBase fixture)
=> Fixture = fixture;
protected T This => (T)this;
public T Assert(Action<T> assert)
{
assert(This);
return This;
}
public virtual ActionPageObject Action(string actionName) => new ActionPageObject(Fixture, actionName);
public T ExecuteAction(Func<T, ActionPageObject> action)
{
action(This).Execute();
return This;
}
public T ExecuteAction<TPageObject>(Func<T, ActionPageObject> action, Func<EasyTestFixtureBase, TPageObject> pageObjectFactory, Action<TPageObject> executor)
{
action(This).Execute();
executor(pageObjectFactory(Fixture));
return This;
}
public T ExecuteActionIf(Predicate<EasyTestFixtureBase> predicate, Func<T, ActionPageObject> action)
{
if (predicate(Fixture))
{
return ExecuteAction(action);
}
return This;
}
public T Do(Action<T> action)
{
action(This);
return This;
}
}
public class NestedListPageObject : NestedListPageObject<NestedListPageObject>
{
public NestedListPageObject(EasyTestFixtureBase fixture, string listName) : base(fixture, listName) { }
}
public abstract class NestedListPageObject<T> : ListPageObject<T>
where T : NestedListPageObject<T>
{
public NestedListPageObject(EasyTestFixtureBase fixture, string listName) : base(fixture, listName) { }
}
public class ApplicationPageObject : ApplicationPageObject<ApplicationPageObject>
{
public ApplicationPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
}
public class ApplicationPageObject<T> : PageObject<T>
where T : ApplicationPageObject<T>
{
public ApplicationPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
public DepartmentListPageObject NavigateToDepartment()
{
Fixture.CommandAdapter.DoAction("Navigation", "Department");
return new DepartmentListPageObject(Fixture);
}
public ContactListPageObject NavigateToContact()
{
Fixture.CommandAdapter.DoAction("Navigation", "Contact");
return new ContactListPageObject(Fixture);
}
public ListPageObject NavigateTo(string navigationName)
{
Fixture.CommandAdapter.DoAction("Navigation", navigationName);
return new ListPageObject(Fixture, "Demo Task");
}
}
public class ListPageObject : ListPageObject<ListPageObject>
{
public ListPageObject(EasyTestFixtureBase fixture, string tableName) : base(fixture, tableName) { }
}
public class ListPageObject<T> : PageObject<T>
where T : ListPageObject<T>
{
protected string TableName { get; }
protected ITestControl TestControl { get; }
public ListPageObject(EasyTestFixtureBase fixture, string tableName) : base(fixture)
{
TableName = tableName;
TestControl = Fixture.Adapter.CreateTestControl(TestControlType.Table, tableName);
}
public TDetailPageObject OpenRecord<TDetailPageObject>(string columnName, string value, Func<EasyTestFixtureBase, TDetailPageObject> pageObjectFactory)
{
Fixture.CommandAdapter.ProcessRecord(TableName, new string[] { columnName }, new string[] { value }, "");
return pageObjectFactory(Fixture);
}
public DetailPageObject OpenRecord(string columnName, string value)
=> OpenRecord(columnName, value, f => new DetailPageObject(f));
public int RowCount => TestControl.GetInterface<IGridBase>().GetRowCount();
public Dictionary<string, string> GetValues(int rowIndex, params string[] columnNames)
=> columnNames.Select(columnName => new
{
ColumnName = columnName,
Value = Fixture.CommandAdapter.GetCellValue(TableName, rowIndex, columnName)
}).ToDictionary(x => x.ColumnName, x => x.Value);
public ActionPageObject NestedAction(string actionName) => base.Action($"{TableName}.{actionName}");
public ActionPageObject UnlinkAction => NestedAction("Unlink");
public ActionPageObject LinkAction => NestedAction("Link");
public T SelectRow(int rowIndex)
{
TestControl.GetInterface<IGridRowsSelection>().SelectRow(rowIndex);
return This;
}
}
public class DepartmentListPageObject : DepartmentListPageObject<DepartmentListPageObject>
{
public DepartmentListPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
}
public class DepartmentListPageObject<T> : ListPageObject<T>
where T : DepartmentListPageObject<T>
{
public DepartmentListPageObject(EasyTestFixtureBase fixture) : base(fixture, "Department") { }
public DepartmentDetailPageObject OpenRecordByTitle(string title)
=> OpenRecord("Title", title, f => new DepartmentDetailPageObject(f));
}
public class ContactListPageObject : ContactListPageObject<ContactListPageObject>
{
public ContactListPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
}
public class ContactListPageObject<T> : ListPageObject<T>
where T : ContactListPageObject<T>
{
public ContactListPageObject(EasyTestFixtureBase fixture) : base(fixture, "Contact") { }
public ContactDetailPageObject OpenRecordByFullName(string title)
=> OpenRecord("Full Name", title, f => new ContactDetailPageObject(f));
}
public class DetailPageObject : DetailPageObject<DetailPageObject>
{
public DetailPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
}
public class DetailPageObject<T> : PageObject<T>
where T : DetailPageObject<T>
{
public DetailPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
public ListPageObject List(string tableName) => new ListPageObject(Fixture, tableName);
public string GetValue(string fieldName) => Fixture.CommandAdapter.GetFieldValue(fieldName);
public void SetValue(string fieldName, string value) => Fixture.CommandAdapter.SetFieldValue(fieldName, value);
public ActionPageObject EditAction => Action("Edit");
public ActionPageObject SaveAction => Action("Save");
public ActionPageObject SaveAndCloseAction => Action("Save and Close");
public ActionPageObject CloseAction => Action("Close");
}
public class ContactDetailPageObject : ContactDetailPageObject<ContactDetailPageObject>
{
public ContactDetailPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
}
public class ContactDetailPageObject<T> : DetailPageObject<T>
where T : ContactDetailPageObject<T>
{
public ContactDetailPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
public string FirstName
{
get => GetValue("First Name");
set => SetValue("First Name", value);
}
public string LastName
{
get => GetValue("Last Name");
set => SetValue("Last Name", value);
}
public string FullName
{
get => GetValue("Full Name");
set => SetValue("Full Name", value);
}
public string Department
{
get => GetValue("Department");
set => SetValue("Department", value);
}
public string Position
{
get => GetValue("Position");
set => SetValue("Position", value);
}
}
public class DepartmentDetailPageObject : DepartmentDetailPageObject<DepartmentDetailPageObject>
{
public DepartmentDetailPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
}
public class DepartmentDetailPageObject<T> : DetailPageObject<T>
where T : DepartmentDetailPageObject<T>
{
public DepartmentDetailPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
public PositionListPageObject Positions()
{
Fixture.CommandAdapter.DoAction("Positions", null);
return new PositionListPageObject(Fixture);
}
public NestedListPageObject Contacts()
{
Fixture.CommandAdapter.DoAction("Contacts", null);
return new NestedListPageObject(Fixture, "Contacts");
}
}
public class PositionListPageObject : PositionListPageObject<PositionListPageObject>
{
public PositionListPageObject(EasyTestFixtureBase fixture) : base(fixture) { }
}
public class PositionListPageObject<T> : NestedListPageObject<T>
where T : PositionListPageObject<T>
{
public PositionListPageObject(EasyTestFixtureBase fixture) : base(fixture, "Positions") { }
}
public class ActionPageObject : ActionPageObject<ActionPageObject>
{
public ActionPageObject(EasyTestFixtureBase fixture, string actionName) : base(fixture, actionName) { }
}
public class ActionPageObject<T> : PageObject<T>
where T : ActionPageObject<T>
{
protected string ActionName { get; }
protected ITestControl TestControl { get; }
public ActionPageObject(EasyTestFixtureBase fixture, string actionName) : base(fixture)
{
ActionName = actionName;
TestControl = Fixture.Adapter.CreateTestControl(TestControlType.Action, actionName);
}
public bool Enabled => TestControl.GetInterface<IControlEnabled>().Enabled;
public T Execute()
{
Fixture.CommandAdapter.DoAction(ActionName, null);
return This;
}
}
}
Okay thats a lot of code, but under the hood it's the same stuff as before. Most of it is boilerplate code. But can you spot the difference? There are two ways to write PageObjects
. declarative and imperative. If you look back at WorkingWithTasks_
method, we didn't write new PageObject classes, instead we used the imperative style. That is a valuable option, if your application is rather large and have a lot of nested views, or some parts of the application that don't change a lot, but I would not recommend that cause you are leaking a lot of implementation details to the test. I rather prefer the declarative approach for 4 reasons:
- Maintainability: Need to fix a caption? Ohhhh.... I need to change 300 tests...
- Focus: What's the action again on that ListView? Can't remember the name, need to lookup in source or start the application
- Localization: If you extend the page object pattern for localized captions, you can test your app in different languages!
- Parameters: You can use parametrized tests more easily
What do you think? Is that something you want to use in your application? Let me know in the comments!
Recap
We learned about writing EasyTests in Code, the Page-Object-Pattern and how we can do functional testing in an maintainable fashion! Test code is equally important production code! Treat it with care! Refactor it like you would do production code!
I promised last time that we cover structuring and testing business logic, I promise the next post will cover that (but it's hard to cover that without a good sample project). And it was so thrilling to do UI-Automation with XAF first.
If you find interesting what I'm doing, consider becoming a patreon or contact me for training, development or consultancy.
I hope this pattern helps testing your applications. The next post in this series will cover testing business logic. The source code the applied pattern is as always on github.
Happy testing!
Comments
Andre S. 28 Jan 2020 19:31
Once again a great article! Thank you!
But I'm not happy with initialization of test data in the ModuleUpdater. And I think it violates your 'clean test data' idea. I would like to init the test data somehow in the EasyTest.Tests project, but actually I don't have an idea how this could be done.
What is your opinion?
Manuel Grundner 29 Jan 2020 10:38
@Andre S. That's an interesting question and you totally could (and probably) do that. As stated in my last article you should reuse as much as code in both "worlds" as much as possible. The only thing that would change is: You need to initialize the data layer differently (with an actual db connection), but now an interesting question arises.
Where to set the boundary of application layer code? Eg. do you want to initialize the testdata in the EasyTestProject or via remote injection inside the System under test.
That's a neat question and I'll write a follow up on that. Long words short answer: you can do both but both solutions have different drawbacks an depend on the application and complexity of the product.
Answer as a teaser: To initialize test data inside the
EasyTest.Tests
project: follow the normal route that you would use to initialize an application (reference the sut project, change the connectionstring's) and call application.Setup(). Afterwards you can call application.CreateObjectSpace() and do whatever you want with the database before and after you interact with the SUT.HOWEVER: Be aware: that will tangle and couple your functional tests with the SUT. That can be a problem with the nature of .NET remoting (in future versions they use named pipes).
Hope that answers you question so far and stay tuned for the follow up post for a deep dive!
Andre S. 3 Feb 2020 10:40
Thank you. But this approach won't work with the InMemoryDataStore, will it? The SUT runs in another process/app domain and therefore the SUT uses an own instance of the InMemoryDataBase. Am I missing something?
Manuel Grundner 3 Feb 2020 11:05
You are correct. That will not work if you work with an inmemory datastore. There are different strategies to tackle that. Use for example an actual database that supports in memory storage (If your database supports that) or use a shared test database or dockerize it.
You also can implement a custom easytest command to execute the task inside the SUT. You also can do something in between: use a file, an named socket, or whatever to indicate that the SUT should reset the InMemoryDataStore and load data from example a stream/XML file/ whatever. This highly depends on the SUT design and level of test you are trying to cover.
Thank you
Your comment will appear in a few minutes.
vosip 19 Mar 2020 11:44
Why in
InMemoryDataStoreProvider
(which is inherited fromInMemoryDataStore
) declaredsyncRoot
rather than usingSyncRoot
from base classInMemoryDataStore
?Manuel Grundner 19 Mar 2020 12:04
I'm not 100% sure. I guess as the time of writing there was no SyncRoot at the time. But it also is possible it's just an mistake. Nothing that breaks tough, but a good catch.
Manuel Grundner 19 Mar 2020 12:10
Altough i think locking is an implementation detail, that shouldn't be shared across inheritance levels.
Thank you
Your comment will appear in a few minutes.
Thank you
Your comment will appear in a few minutes.