The Lazy<> way to learn new tech
Today I thought I would write a little about how I learn to use new tech / language features. I read a lot of people on Twitter who complete many online courses but never really feel that they are ready to actually start ‘doing’ development. Personally that is not how I find I learn best, and in reality it is not how you are going to learn new tech once you actually manage to get a job in software development.
Apologies if you are waiting for me to write about my ideas on how to implement C# PageObjects / page controllers. The work is ongoing and there is some usable code on GitHub. Sometimes life gets in the way and I’m struggling with a major writer’s block on starting that.
Learning a new tech for your job
Very occassionally a company may decide to implement a new tech and might invest some developer time and possibly even pay for training courses of some kind.
More typically, you might pick up an item from the backlog that clearly requires some tech, library or approach that you haven’t tried before. When you are working on someone elses time, you always have to make the trade off between learning the ‘right way’ (best practice or new approach) and finding a less elegant method that works.
Typical approach
- A little searching and finding suggestions about how to tackle the problem.
- Identifying an appropriate tech or programing pattern to use
- Finding a blog post or tutorial of someone solving a problem using that approach.
- Writing a quick proof of concept to show that it can work.
- Implementing the approach within your product.
- Writing unit tests and exploratory testing. (or maybe you are writing your tests before code using TDD)
Personally I find that solving real world problems like this is the best way to understand how something really works, and to get a feel for its advantages as well as drawbacks. Of course my final step then is to blog about it to reinforce the learning, and document it if I ever find a need for it again.
So how did this post come about?
As I said I have made some progress with my PageObject controller library, but I am struggling with writers block on sharing my thoughts. Whilst surfing the Ministry of Testing I can across a post about useful online test resources for practising WebDriver.
I was inspired by a linked post from the MoT Boss Boss Richard Bradshaw from 2012 about building a generic table controller using XPath locators.
Now for the Tech stuff…
I’ll be getting into the tech stuff now so if code makes your head hurt feel free to skip to the end now. (That was for Laura our recruiter who always read my posts.) On the other hand, if C#
is your thing lets get on with the good stuff…..
If you also happen to be a .net developer near Reading who might be interested in helping us at Altitude Angel to keep drones and planes safely separated in the skies; Do check for vacancies and contact myself or Laura. We are almost always on the lookout for talented developers. Sorry no SDET vacancies right now though.
Lazy part One. TRIMS
If you had ever asked me about locators, you would know that I have an almost pathological hatred of using XPaths. So I quickly cloned the repository and thought about how to split it into smaller objects. Before long I had started applying Richard’s latest thing: TRIMS.
- Targeted
- Reliable
- Informative
- Maintainable
- Speedy
The S stands for speedy, and these tests fired up a new WebDriver for each of the 11 tests, even though they were all reading from the same WebPage without changing anything.
Adding 14 characters to change [Setup]
and [TearDown]
to [OneTimeSetup]
and [OneTimeTearDown]
respectively and a 3 minute run time drops to 2 seconds for all 11 tests!
I think that qualifies as both speedy and lazy. 2 seconds is quite long enough to wait, and making a local copy of the page takes it down to reporting 1 second.
The magic bit (Not Lazy)
Do bear in mind that Richard wrote this as a proof of concept (step 4) seven years ago and the point was to explore using XPaths, so I am not suggesting that he did it wrong… After all, with his experience I am sure that he has forgotten more than I have learnt yet about automation testing.
However there are a few things that I would not do this way:
Abstraction:
I think that the controller itself should handle ALL interactions with the WebDriver. All methods should either:
- return data (usually a string or number)
- return a controller for a smaller part of the page
- perform an action (e.g. click or select) and return void
The tests that he wrote however check for returned WebElements so I will not remove this functionality.
Separation of concerns:
I will however use 2 controller objects instead of one.
- SimpleTable – a controller for an html table with unique headers
- TableRow – a controller for handling a single row of the table
The best part is that of course Richard has already written the tests for me. I am refactoring so I just need to ensure that I fix any tests that I break… 🙂
So here is the slightly ‘TRIMS’ed test class:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using NUnit.Framework; using ControlObjects; using OpenQA.Selenium; using OpenQA.Selenium.Firefox; namespace TableControlObjectDemoTests { /// <summary> /// Tests to verify the Table Control object against the W3C site. /// </summary> [TestFixture] public class TableTests { protected IWebDriver WebDriver; [OneTimeSetUp] public void Setup() { WebDriver = new FirefoxDriver(); WebDriver.Navigate().GoToUrl("http://www.thefriendlytester.co.uk/2012/12/table-controlobject.html"); WebDriver.Manage().Window.Maximize(); } [OneTimeTearDown] public void TearDown() { WebDriver.Dispose(); } [Test] public void VerifyColumnHeaders() { List<string> ExpectedColumnHeaders = new List<string>(); ExpectedColumnHeaders.Add("Company"); ExpectedColumnHeaders.Add("Contact"); ExpectedColumnHeaders.Add("Country"); Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); Assert.AreEqual(ExpectedColumnHeaders, w3cTable.ReadAllColumnHeaders()); } [Test] public void VerifyColumnCount() { Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); Assert.AreEqual(3, w3cTable.ColumnCount()); } /// <summary> /// Note there is actually 12 rows, but /// </summary> [Test] public void VerifyRowCount() { Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); Assert.AreEqual(12, w3cTable.RowCount()); } [Test] public void VerifyRowContentsUsingKnownColumnValue() { Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); Assert.AreEqual("Alfreds Futterkiste Maria Anders Germany", w3cTable.FindRowMatchingColumnData("Company", "Alfreds Futterkiste").Text); } [Test] public void VerifyRowContentContainingKnownValue() { Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); Assert.AreEqual("Laughing Bacchus Winecellars Yoshi Tannamuri Canada", w3cTable.FindFirstRowByKnownValue("Canada").Text); } [Test] public void DoesColumnContain() { Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); Assert.IsTrue(w3cTable.IsValuePresentWithinColumn("Country", "Denmark")); Assert.IsFalse(w3cTable.IsValuePresentWithinColumn("Country", "Greece")); } [Test] public void FindCellByKnownColumnValue() { Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); IWebElement matchedCell = w3cTable.FindCellByColumnAndKnownValue("Company", "North/South"); //You probably wouldn't then compare text, as you used that to find it, but just showing it found element Assert.AreEqual("North/South", matchedCell.Text); } [Test] public void FindCellByRowAndColumnName() { Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); //You could potentially already have the row element, perhaps by find it a different way, but using a nother table methond here to get a row IWebElement requiredRow = w3cTable.FindRowMatchingColumnData("Company", "The Big Cheese"); Assert.AreEqual("Liz Nixon", w3cTable.FindCellByRowAndColumnName(requiredRow, "Contact").Text); } [Test] public void FindCellByColumnAndRowNumber() { Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); Assert.AreEqual("Giovanni Rovelli", w3cTable.FindCellByColumnAndRowNumber("Contact", 8).Text); } [Test] public void VerifyAllColumnData() { List<string> ExpectedColumnData = new List<string>(); ExpectedColumnData.Add("Germany"); ExpectedColumnData.Add("Sweden"); ExpectedColumnData.Add("Mexico"); ExpectedColumnData.Add("Austria"); ExpectedColumnData.Add("UK"); ExpectedColumnData.Add("Germany"); ExpectedColumnData.Add("Canada"); ExpectedColumnData.Add("Italy"); ExpectedColumnData.Add("UK"); ExpectedColumnData.Add("France"); ExpectedColumnData.Add("USA"); ExpectedColumnData.Add("Denmark"); Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); Assert.AreEqual(ExpectedColumnData, w3cTable.ReadAllDataFromAColumn("Country")); } [Test] public void VerifyFranciscoChangCompany() { Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers"))); Assert.AreEqual("Centro comercial Moctezuma", w3cTable.ReadAColumnForRowContainingValueInColumn("Company", "Francisco Chang", "Contact")); } } }
And the original XPath locator based controller.
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using OpenQA.Selenium; using System.Collections.ObjectModel; namespace ControlObjects { public class Table { protected IWebElement _tableBody; //Protected reference to the table body. protected List<IWebElement> _tableHeaders; //List of all the tableHeaders used to get the column indexs. protected List<IWebElement> _tableRows; //List of all the rows to loop through to look for matches. public Table(IWebElement table) { try { //Look for and assign the tbody element _tableBody = table.FindElement(By.TagName("tbody")); } catch (Exception ex) { throw new Exception(string.Format("Couldn't find a tbody tag for this table. {0}", ex.Message)); } try { //Look for all the table headers _tableHeaders = table.FindElements(By.TagName("th")).ToList(); } catch (Exception ex) { throw new Exception(string.Format("Couldn't find any th tags within this table. {0}", ex.Message)); } try { //Look for all the table rows _tableRows = _tableBody.FindElements(By.TagName("tr")).ToList(); } catch (Exception ex) { throw new Exception(string.Format("This table doesn't contain any rows. {0}", ex.Message)); } } protected int FindColumnIndex(string columnName) { IWebElement desiredColumn = null; try { desiredColumn = _tableHeaders.First(d => d.Text.Trim() == columnName); } catch (Exception ex) { if (ex is ArgumentNullException) { throw new Exception(string.Format("Unable to find {0} in the list of columns found", columnName)); } if (ex is InvalidOperationException) { throw new Exception(string.Format("More than one column was found for {0}", columnName)); } else { throw; } } //We have to add one as list if zero indexed, however we would say column 1 not 0, and also XPath isn't 0 based. return _tableHeaders.IndexOf(desiredColumn) + 1; } /// <summary> /// Find the first row that contains the specified value in the specified column /// </summary> /// <param name="columnName">The column to look for the value in</param> /// <param name="knownValue">The value to look for</param> /// <returns>The matching row element</returns> public IWebElement FindRowMatchingColumnData(string columnName, string knownValue) { IWebElement requiredRow = null; int columnIndex = FindColumnIndex(columnName); try { requiredRow = _tableRows.First(d => d.FindElement(By.XPath(string.Format("td[{0}]", columnIndex))).Text == knownValue); } catch (Exception ex) { if (ex is NoSuchElementException) { throw new Exception(string.Format("Column index {0} doesn't exist", columnIndex)); } if (ex is ArgumentNullException) { throw new Exception(string.Format("Row containing {0} in column index {1} was not found", knownValue, columnIndex)); } else { throw; } } if (requiredRow != null) { return requiredRow; } throw new Exception("Required row is null, unknown error occured"); } /// <summary> /// Find a row that comtains the given value in any column /// </summary> /// <param name="knownValue">The value to look for</param> /// <returns>The matching row element</returns> public IWebElement FindFirstRowByKnownValue(string knownValue) { int i; i = 1; while (i <= _tableHeaders.Count()) { foreach (IWebElement row in _tableRows) { if (row.FindElement(By.XPath(string.Format("td[{0}]", i))).Text == knownValue) { return row; } } i++; } throw new Exception(string.Format("Unable to find a row containing: {0} in a column", knownValue)); } /// <summary> /// Check to see if a value is within a specified column /// </summary> /// <param name="columnName">The column name to check in</param> /// <param name="knownValue">The value to look for</param> /// <returns>True is the value is found</returns> public bool IsValuePresentWithinColumn(string columnName, string knownValue) { try { IWebElement matchedColumn = _tableRows.First(d => d.FindElement(By.XPath(string.Format("td[{0}]", FindColumnIndex(columnName)))).Text == knownValue); } catch (Exception) { return false; } return true; } /// <summary> /// Find a cell by the column name and known value /// </summary> /// <param name="columnName">Column to look for the known value in</param> /// <param name="knownValue">The known value to look for</param> /// <returns>Matching cell element</returns> public IWebElement FindCellByColumnAndKnownValue(string columnName, string knownValue) { IWebElement matchedRow = null; try { matchedRow = _tableRows.First(d => d.FindElement(By.XPath(string.Format("td[{0}]", FindColumnIndex(columnName)))).Text == knownValue); } catch (Exception) { throw new Exception(string.Format("Unable to find a cell in column: {0} containing {1}", columnName, knownValue)); } return matchedRow.FindElement(By.XPath(string.Format("td[{0}]", FindColumnIndex(columnName)))); } /// <summary> /// Find a cell my using the row element and the column name /// </summary> /// <param name="row">The row element to read column value from</param> /// <param name="columnName">The column name to read value from</param> /// <returns>Matching cell element</returns> public IWebElement FindCellByRowAndColumnName(IWebElement row, string columnName) { IWebElement cell; try { cell = row.FindElement(By.XPath(string.Format("td[{0}]", FindColumnIndex(columnName)))); } catch (Exception) { throw new Exception("Unable to find a cell using given row and columnName"); } return cell; } /// <summary> /// Returns a list of all the column headers /// </summary> /// <returns>A list of all the column names as strings</returns> public List<string> ReadAllColumnHeaders() { List<string> columnNames = new List<string>(); foreach (IWebElement element in _tableHeaders) { columnNames.Add(element.Text); } return columnNames; } /// <summary> /// Return the number of columns from the table /// </summary> /// <returns>Number of columns as an int</returns> public int ColumnCount() { return _tableHeaders.Count(); } /// <summary> /// Returns the number of rows in the table /// </summary> /// <returns>Number of rows as an int</returns> public int RowCount() { return _tableRows.Count(); } /// <summary> /// Find a cell by column name and the row number /// </summary> /// <param name="columnName">The name of the column to read from</param> /// <param name="row">The number of the row</param> /// <returns>Matching cell element</returns> public IWebElement FindCellByColumnAndRowNumber(string columnName, int row) { IWebElement matchingCell = _tableBody.FindElement(By.XPath(string.Format("tr[{0}]/td[{1}]", row, FindColumnIndex(columnName)))); return matchingCell; } /// <summary> /// Read all the data from a specific column /// </summary> /// <param name="columnName">The name of the column to read from</param> /// <returns>A list of all column data as strings</returns> public List<string> ReadAllDataFromAColumn(string columnName) { List<string> columnData = new List<string>(); int columnIndex = FindColumnIndex(columnName); foreach (IWebElement row in _tableRows) { columnData.Add(row.FindElement(By.XPath(string.Format("td[{0}]", columnIndex))).Text); } return columnData; } /// <summary> /// Read the value of a cell for the row that contains a known value /// </summary> /// <param name="columnName">The column name to read value from</param> /// <param name="knownValue">The value to find a matching row</param> /// <returns>The value of cell as a string</returns> public string ReadColumnValueForRowContaining(string columnName, string knownValue) { IWebElement requiredRow = FindFirstRowByKnownValue(knownValue); IWebElement requiredCell = FindCellByRowAndColumnName(requiredRow, columnName); return requiredCell.Text; } /// <summary> /// Read a cells value by finding the value using a known vale from a specific column /// </summary> /// <param name="columnToRead">Column to read data from</param> /// <param name="knownValue">Known value to match</param> /// <param name="knownValueColumn">Column which should contain the known value</param> /// <returns>Value of the matching cell as a string</returns> public string ReadAColumnForRowContainingValueInColumn(string columnToRead, string knownValue, string knownValueColumn) { IWebElement requiredRow = FindRowMatchingColumnData(knownValueColumn, knownValue); IWebElement requiredCell = FindCellByRowAndColumnName(requiredRow, columnToRead); return requiredCell.Text; } } }
So what did I change?
The test.
Being lazy again, I can double down on doing things once as every test contains exactly the same line Table w3cTable = new Table(WebDriver.FindElement(By.Id("customers")));
As again we are merely reading rather than interacting with a static table (no fancy ajax here) we can just create our controller object once as a private field and use it in every test. That drops the time down even further to a reported 1 second.
I also changed my controller implementation name to SimpleTable to reflect that the tables it supports would be quite restrictive.
SimpleTable
using System; using System.Collections.Generic; using System.Linq; using OpenQA.Selenium; namespace ControlObjects { public class SimpleTable { public SimpleTable(IWebDriver webDriver, By tableRootElementBy) { RootElement = webDriver.FindElement(tableRootElementBy); try { //Look for and assign the tbody element TableElement = RootElement.FindElement(By.TagName("tbody")); } catch (Exception ex) { throw new Exception($"Couldn't find a tbody tag for this table. {ex.Message}"); } try { //Look for all the table headers List<IWebElement> tableHeaders = RootElement.FindElements(By.TagName("th")).ToList(); int i = 0; IDictionary<string, int> columnNameDictionary = new Dictionary<string, int>(); tableHeaders.ForEach(we => columnNameDictionary.Add(we.Text.Trim(), i++)); ColumnNameDictionary = columnNameDictionary; } catch (ArgumentException ex) { throw new ArgumentException("Multiple columns have the same name", ex); } catch (Exception ex) { throw new Exception($"Couldn't find any th tags within this table. {ex.Message}"); } List<TableRow> tableRows = new List<TableRow>(); try { //Look for all the table rows List<IWebElement> tableRowElements = TableElement.FindElements(By.TagName("tr")).ToList(); tableRowElements.ForEach(we => tableRows.Add(new TableRow(we))); TableRows = tableRows; } catch (Exception ex) { throw new Exception($"This table doesn't contain any rows. {ex.Message}"); } } protected IWebElement RootElement { get; } protected IWebElement TableElement { get; } public IDictionary<string, int> ColumnNameDictionary { get; } public List<TableRow> TableRows { get; } protected int FindColumnIndex(string columnName) { if (!ColumnNameDictionary.TryGetValue(columnName, out int desiredColumnIndex)) { throw new Exception($"Unable to find {columnName} in the list of columns found"); } return desiredColumnIndex; } /// <summary> /// Find the first row that contains the specified value in the specified column /// </summary> /// <param name="columnName">The column to look for the value in</param> /// <param name="knownValue">The value to look for</param> /// <returns>The matching row element</returns> public IWebElement FindRowMatchingColumnData(string columnName, string knownValue) { IWebElement requiredRow = null; int columnIndex = FindColumnIndex(columnName); try { requiredRow = TableRows.First(d => d.GetTextFromNthColumn(columnIndex) == knownValue).RootElement; } catch (Exception ex) { if (ex is NoSuchElementException) { throw new Exception($"Column index {columnIndex} doesn't exist"); } if (ex is ArgumentNullException) { throw new Exception($"Row containing {knownValue} in column index {columnIndex} was not found"); } else { throw; } } if (requiredRow != null) { return requiredRow; } throw new Exception("Required row is null, unknown error occured"); } /// <summary> /// Find a row that contains the given value in any column /// </summary> /// <param name="knownValue">The value to look for</param> /// <returns>The matching row element</returns> public IWebElement FindFirstRowByKnownValue(string knownValue) { try { return TableRows.First(tr => tr.AnyCellContainsValue(knownValue)).RootElement; } catch (InvalidOperationException ex) { throw new Exception($"Unable to find a row containing: {knownValue} in a column", ex); } } /// <summary> /// Check to see if a value is within a specified column /// </summary> /// <param name="columnName">The column name to check in</param> /// <param name="knownValue">The value to look for</param> /// <returns>True is the value is found</returns> public bool IsValuePresentWithinColumn(string columnName, string knownValue) { try { int columnIndex = FindColumnIndex(columnName); IWebElement matchedColumn = TableRows.First(tr => tr.GetTextFromNthColumn(columnIndex) == knownValue).RootElement; } catch (InvalidOperationException) { return false; } return true; } /// <summary> /// Find a cell by the column name and known value /// </summary> /// <param name="columnName">Column to look for the known value in</param> /// <param name="knownValue">The known value to look for</param> /// <returns>Matching cell element</returns> public IWebElement FindCellByColumnAndKnownValue(string columnName, string knownValue) { TableRow matchedRow = null; int columnIndex = FindColumnIndex(columnName); try { matchedRow = TableRows.First(tr => tr.NthCellContainsValue(columnIndex, knownValue)); } catch (Exception) { throw new Exception(string.Format("Unable to find a cell in column: {0} containing {1}", columnName, knownValue)); } return matchedRow.GetElementFromNthColumn(columnIndex); } /// <summary> /// Find a cell my using the row element and the column name /// </summary> /// <param name="row">The row element to read column value from</param> /// <param name="columnName">The column name to read value from</param> /// <returns>Matching cell element</returns> public IWebElement FindCellByRowAndColumnName(IWebElement row, string columnName) { TableRow rowController = new TableRow(row); return rowController.GetElementFromNthColumn(FindColumnIndex(columnName)); } /// <summary> /// Returns a list of all the column headers /// </summary> /// <returns>A list of all the column names as strings</returns> public List<string> GetAllColumnHeaders() { return ColumnNameDictionary.Keys.ToList(); } /// <summary> /// Return the number of columns from the table /// </summary> /// <returns>Number of columns as an int</returns> public int ColumnCount() { return ColumnNameDictionary.Count(); } /// <summary> /// Returns the number of rows in the table /// </summary> /// <returns>Number of rows as an int</returns> public int RowCount() { return TableRows.Count(); } /// <summary> /// Find a cell by column name and the row number /// </summary> /// <param name="columnName">The name of the column to read from</param> /// <param name="rowNumber">The number of the row</param> /// <returns>Matching cell element</returns> public IWebElement FindCellByColumnAndRowNumber(string columnName, int rowNumber) { IWebElement matchingCell = TableRows[rowNumber - 1].GetElementFromNthColumn(FindColumnIndex(columnName)); return matchingCell; } /// <summary> /// Read all the data from a specific column /// </summary> /// <param name="columnName">The name of the column to read from</param> /// <returns>A list of all column data as strings</returns> public List<string> ReadAllDataFromAColumn(string columnName) { // We could easily implement a TableColumn object, but reading from the rows works well List<string> columnData = new List<string>(); int columnIndex = FindColumnIndex(columnName); foreach (TableRow row in TableRows) { columnData.Add(row.GetTextFromNthColumn(columnIndex)); } return columnData; } /// <summary> /// Read a cells value by finding the value using a known value from a specific column /// </summary> /// <param name="columnToRead">Column to read data from</param> /// <param name="knownValue">Known value to match</param> /// <param name="knowColumn">Column which should contain the known value</param> /// <returns>Value of the matching cell as a string</returns> public string ReadAColumnForRowContainingValueInColumn(string columnToRead, string knownValue, string knowColumn) { IWebElement requiredRow = FindRowMatchingColumnData(knowColumn, knownValue); IWebElement requiredCell = FindCellByRowAndColumnName(requiredRow, columnToRead); return requiredCell.Text; } } }
It is not much shorter, and I am not a big fan of the massive constructor, but it works using my implementation of a TableRow.
using System.Collections.Generic; using System.Linq; using OpenQA.Selenium; namespace ControlObjects { public class TableRow { public TableRow(IWebElement trElement) { RootElement = trElement; RowCells = RootElement.FindElements(By.TagName("td")).ToList();; } private List<IWebElement> RowCells { get; } public IWebElement RootElement { get; } public string GetRowText() { return RootElement.Text; } public string GetTextFromNthColumn(int columnIndex) { return RowCells.Count > columnIndex ? RowCells[columnIndex].Text : ""; } public IWebElement GetElementFromNthColumn(int columnIndex) { return RowCells.Count > columnIndex ? RowCells[columnIndex] : null; } public bool AnyCellContainsValue(string searchValue) { var result = RowCells.FirstOrDefault(we => we.Text.Trim().Equals(searchValue)); return result != null; } public bool NthCellContainsValue(int columnIndex, string searchValue) { return RowCells[columnIndex].Text.Trim().Equals(searchValue); } private List<IWebElement> InitRowCells() { return RootElement.FindElements(By.TagName("td")).ToList();; } } }
And the result:
Around half a second now. Not bad. This is largely because the in set up the controller identifies the column headers and indexes rather than making WebDriver calls to do this for each call to the controller.
It might make things a little slower if you only make one call to the table, but it then saves time on any further calls.
Lazy part Two. Lazy<>
So what has Lazy<> got to do with it I hear you ask?
Remember that large constructor that I was complaining about? It also commits what I consider to be a cardinal sin of control objects – it makes WebDriver calls in the constructor. This might not seem such a big deal, but it ties you in to creating the controller once the relevant object has already been loaded into the browser. I had to solve exactly this problem implementing Block controllers in Java as discussed in this post.
This requires me to load each page before generating the objects. I prefer to use dependency injection to create all my controllers first so how can this be done?
I had to solve exactly this problem implementing Block controllers in Java as discussed in this post.
Here I will use the concept of lazily initialised fields. Basically I define what the value should be, but only get the value the first time that it is used.
It is perfectly possible to write your own implementation to support this, but C# has Lazy<T>
for exactly this use case.
If you remember, I said at the top that I find it much easier to learn something by implementing it into something I already know? Finally this is my excuse to get Lazy<T>.
Getting Lazy – step One
Firstly lets make it fail. That’s pretty simple. Just rearrange my test Setup to instantiate the controller before loading the page.
[OneTimeSetUp] public void Setup() { WebDriver = new FirefoxDriver(); w3cTable = new SimpleTable(WebDriver, By.Id("customers")); WebDriver.Manage().Window.Maximize(); WebDriver.Navigate().GoToUrl("file:///C:/src/Table-ControlObject/TableControlObjectDemo/Page/Table_ControlObject_FriendlyTester.html"); }
Now the tests fail almost instantly.
Getting Lazy – step Two
So lets get straight on with fixing that ridiculous constructor for SimpleTable. To do this I am going to
- use backing fields for the properties that are Lazy<T> instances
- Write an ‘InitT’ method for each that performs the initialisation once the controller is called with the page loaded in the browser.
- initialise them in the constructor t = new Lazy<T>(InitT);
- Set each property up to only read the backing field.
The magic is in the last step:
protected T T => t.Value;
is used here.
t.Value calls to the lazy object and will initialise it using the defined initialisation method if it has not yet been called. Note that does mean that it is from that point a readonly property, which works fine for me here on a static table.
My first attempts were to set the property as the lazy object. This is a bad idea as you have to use xxx.Value everywhere you want to use it. Instead, have the backing field as lazy and the property return the required object.
This makes the top of my Simple Table class much cleaner.
public class SimpleTable { private IWebDriver WebDriver; private By TableRootElementBy; private readonly Lazy<IWebElement> rootElement; //{table} tag private readonly Lazy<IWebElement> tableElement; //Protected reference to the table body. {tbody} private readonly Lazy<IDictionary<string, int>> columnNameDictionary; //Dictionary of Column indices by name. private readonly Lazy<List<TableRow>> tableRows; //List of RowControllers for each data row public SimpleTable(IWebDriver webDriver, By tableRootElementBy) { WebDriver = webDriver; TableRootElementBy = tableRootElementBy; rootElement = new Lazy<IWebElement>(InitRootElement); tableElement = new Lazy<IWebElement>(InitTableElement); columnNameDictionary = new Lazy<IDictionary<string, int>>(InitColumnNameDictionary); tableRows = new Lazy<List<TableRow>>(InitTableRows); } protected IWebElement RootElement => rootElement.Value; protected IWebElement TableElement => tableElement.Value; public IDictionary<string, int> ColumnNameDictionary => columnNameDictionary.Value; public List<TableRow> TableRows => tableRows.Value;
And I’ll initialise the Lazy backing fields in private methods (at the bottom)
#region LazyInitialization private IWebElement InitRootElement() { return WebDriver.FindElement(TableRootElementBy); } private IWebElement InitTableElement() { try { //Look for and assign the tbody element return RootElement.FindElement(By.TagName("tbody")); } catch (Exception ex) { throw new Exception($"Couldn't find a tbody tag for this table. {ex.Message}"); } } private IDictionary<string, int> InitColumnNameDictionary() { try { //Look for all the table headers List<IWebElement> tableHeaders = RootElement.FindElements(By.TagName("th")).ToList(); int i = 0; IDictionary<string, int> columnNameDictionary = new Dictionary<string, int>(); tableHeaders.ForEach(we => columnNameDictionary.Add(we.Text.Trim(), i++)); return columnNameDictionary; } catch (ArgumentException ex) { throw new ArgumentException("Multiple columns have the same name", ex); } catch (Exception ex) { throw new Exception($"Couldn't find any th tags within this table. {ex.Message}"); } } private List<TableRow> InitTableRows() { List<TableRow> tableRows = new List<TableRow>(); try { //Look for all the table rows List<IWebElement> tableRowElements = TableElement.FindElements(By.TagName("tr")).ToList(); tableRowElements.ForEach(we => tableRows.Add(new TableRow(we))); return tableRows; } catch (Exception ex) { throw new Exception($"This table doesn't contain any rows. {ex.Message}"); } } #endregion
This is enough to make the tests pass, but I should follow the same pattern with the TableRow class.
Top:
private readonly Lazy<List<IWebElement>> rowCells; public TableRow(IWebElement trElement) { RootElement = trElement; rowCells = new Lazy<List<IWebElement>>(InitRowCells); } private List<IWebElement> RowCells => rowCells.Value;
Bottom:
private List<IWebElement> InitRowCells() { return RootElement.FindElements(By.TagName("td")).ToList();; }
et voilà
802 milliseconds runtime and if I point it to Richard’s original post instead of the local copy it is a little slower at 961 ms a 30 times speed improvement over the orginial. No need to wait around for the results.
In Summary
Progress made:
- Finally unblocked myself in writing a proper technical post.
- Had fun (OK I’m weird) exploring Lazy<T>
Lessons learnt:
- I find it easiest to learn something new by modifying exisiting code, even when its not mine.
- It is even easier when you are refactoring something with a complete set of tests (TDD FTW)
- Lazy<T> is a pretty neat way if you might need something that is expensive to make, or you need to initialise your object at some unspecified time after creation.
- Much as Lazy<T> is pretty neat, I prefer to write controllers that directly use locators.
Credits
Thanks to Richard Bradshaw and Heather Reid from the Ministry of Testing for the inspiration behind this post.
A reminder:
If you want to ask me a question, Twitter is undoubtedly the fastest place to get a response: My Username is @AlexanderOnTest so I am easy to find. My DMs are always open for questions, and I publicise my new blog posts there too.
One thought on “The Lazy way to learn new tech”
Comments are closed.