Using MutationObservers to test Angular in C#

387 views
Skip to first unread message

salo...@gmail.com

unread,
Nov 8, 2018, 12:53:24 AM11/8/18
to Selenium Users

I know many people are turning to Protractor for testing web-based applications using Angular. However, many sites use a combination of technologies and using Protractor is not a practical solution. While digging around on the internet, I found several references to using MutationObservers for detecting changes to the DOM. It took me awhile, but I did manage to solve the mixed technolgies problem using MutationObservers with Selenium in C#


Below is a sort of 'Cookbook' on how I implemented MutationObservers to watch for changes in the DOM on an Angular page


--------------------------------------------------------------------------------------

Problem: Detecting a change in the DOM based on an event

Solution: Watch for the change after executing the event

How: Inject a Mutation Observer

 

Single page applications and modals all cause headaches when attempting to automate a website using old-school methods. The test can wait and poll for a particular element to appear or disappear. The element may already exist in the DOM and is only subjected to an attribute modification. Or, the element may be entirely new to the DOM . It might even exist in the DOM and is moved to a new location for display.  Any of those behaviors are tricky to detect when waiting on a particular element and that waiting condenses down to a guessing game of what exactly should the test wait for and how long should it wait? If the test is waiting on a modal for loading, should the test wait for the modal to disappear? A new element to appear? Which new element? And, what if the behavior or loading image changes? Attempting to detect when a modal appears and renders before executing any action or attempting to detect when the modal disappears before executing an action is fragile and increases automation frustration. 

 

If the need is to detect when a node is added, removed or an attribute modified, it is easier and less brittle to inject a MutationObserver to watch for the expected change. In a nutshell, a MutationObserver provides the ability to watch for changes being made in the DOM tree. Introduced in the DOM4 Specification, MutationObservers replaced Mutation Events. And since they are part of the specification, they are available on all the major browsers.

 

What kind of changes can a MutationObserver observe?  That depends on how the observer is configured. An observer can watch for changes in an attribute, whether a node was added or removed and changes to the character data of a node. Options for the observer are set in the configuration parameter and, by default, all are initially set to false.

 

The Test: Verify that after clicking the ‘email’ button, a listing of all available emails are displayed on a page

The Problem: While the emails are being collected, a loading modal covers the entire page and does not disappear until the system is done collecting the emails. The length of time the modal exists is dependent on the number of emails it needs to collect. The automation scripts cannot proceed until the emails are done loading and the modal is removed.

 

First Objective: Determine which node to watch and how to watch it.

 

Start by observing the modal element which, in this case is directly under ‘body > div.cdk-overlay-container’. The highlight node corresponds to the displayed element when examined using the browser’s dev tools.

 

<body>

<app-root>

<iframe>

<div class="cdk-overlay-container">

<div class="cdk-overlay-backdrop></div>

<div class="cdk-global-overlay-wrapper>

……..

</body>

 

Since the part of the test's objective is to determine when a modal is added and then removed, the configuration should turn on the childlist and subtree option:

 

var config = {childList: true, subtree: true};

 

Before automating the test, it is best to narrow down the scope of the MutationObserver by using a process of elimination. The first run watches in broad in scope and the resulting information is easily digested with the affect of limiting the scope of the observation to something better quantified.

 

 When inspecting the highlighted element, it becomes obvious this is the element to monitor for change. But, that element has two ancestors: a div and body.  For now, watch the <body> node and turn both childlist and subtree to true.

 

var config = {childList: true, subtree: true};

var element = document.querySelector('body');

 

var seleniumObserver = window.document.MutationObserver || {};

 

window.seleniumObserver = {

observer: new MutationObserver(function (mutationsList) {

for (let mutation of mutationsList) {

console.log(mutation);

}

}),

};

 

That will set up the observer in the DOM and watch for all changes where a node is added OR removed below 'body'. Copy and paste the above code into the console window for the page under test. To actually start watching, enter the command: 

 

window.seleniumObserver.observer.observe(element, config);

 

and execute the action on the page that will generate the modal. Then watch the console window for results.

 

Machine generated alternative text:
var 
var 
var 
config = 
element = 
{childList: true, subtree: true}; 
document . guerySeIector( ' body ' ) ; 
seleniumObserver = window. document.MutationObserver I { } ; 
seleniumObserver = { 
obsen.'er: new (mutationsList) { 
for (let mutation of mutationsList) 
console . log(mutation); 
L. (Gnonymous function) } 
{observer: 
> window. observer.observe(elenent, config); 
undefined 
MutationRecord 
MutationRecord 
MutationRecord 
MutationRecord 
MutationRecord 
MutationRecord 
MutationRecord 
MutationRecord 
MutationRecord 
{type: "chiLdList", 
{type: "chiLdList", 
{type: "chiLdList", 
{type: "chiLdList", 
{type: "chiLdList", 
{type: "chiLdList", 
{type: "chiLdList", 
{type: "chiLdList", 
{type: "chiLdList", 
target: body, addedNodes: NodeList(I), removedNodes: NodeList(ø), previousSibLing: iframe#embed, m} 
target: div.cdk-overLay-contGiner, addedNodes: NodEList(I), removedNodes: NodEList(e), previousSibIing: nutL, 
target: div#cdk-overLay-e.cdk-overIay-pane, addedNodes: NodeList(I), removedNodes: NodeList(ø), previousSibLing: nuLt, 
target : 
target: 
target : 
target: 
target: 
div. cdk-overLay-contGiner, addedNodes: NodEList(I), removedNodes: NodEList(e), previousSibIing: nutL, 
-dialog-container.mat-dialog-container.ng-tns-cß-l.ng-trigger .ng-trigger-sIideDiGLog.ng-star-ins., addedNodes: NodeList(I), removedNodes: 
mat 
div. cdk-overLay-contGiner, addedNodes: NodEList(I), removedNodes: NodEList(e), previousSibIing: div.cdk-overLay-backdrop. initiate-case-modal, 
div. cdk-overLay-contGiner, addedNodes: NodEList(e), removedNodes: NodEList(I), previousSibIing: div.cdk-gIobaL-overIay-wrapper, 
div. C#-global -overlay-wrapper, addedNodes: NodEList(I), removedNodes: NodEList(e), previousSibIing: nutL, 
target: header. modal-header, addedNodes: NodeList(I), removedNodes: NodeL ist(ø), previousSibLing: comment, m}

 

The list is long and contains both added and removed nodes. And, there is the 'div.cdk-overlay-container' we saw when examining the modal. Since this node is a direct child of the ‘body’ node, two modification will narrow the scope of the observation: only watch for childList changes and limit the results to only added nodes. The modified observer:

 

var config = {childList: true};

var element = document.querySelector('body');

 

var seleniumObserver = window.document.MutationObserver || {};

 

window.seleniumObserver = {

observer: new MutationObserver(function (mutationsList) {

for (let mutation of mutationsList) {

if(mutation.addedNodes.length >= 1) {

console.log(mutation);

}

}

}),

};

 

Execute the event to generate the modal, and the results are a single hit:

 

Machine generated alternative text:
var 
var 
var 
config = 
element = 
{childList: true}; 
document . guerySeIector( ' body ' ) ; 
seleniumObserver = window. document.MutationObserver I { } ; 
v•indov•. seleniumObserver = { 
obsen.'er: new (mutationsList) { 
for (let mutation of mutationsList) 
if(mutation . length I) 
console. log(mutation) ; 
_ —L. (anonymous function) } 
{observer: 
> window. observer.observe(elenent, config); 
undefined 
MutationRecord {type: "chiLdList", target: body, addedNodes: 
NodeL i st (I ) , 
remo v edNodes : 
NodeL i st (O) , 
previousSibLing : 
ifr a.me#embed, m}

 

With the above configuration, the test can now watch for a single node added to the DOM and that node is the modal. That is fine, except the javascript has to communicate back to the selenium automation that the event occurred.  Instead of the line "console.log(mutation)", have the script set a boolean variable to 'True' from 'False' and then disconnect the MutationObserver:

 

var config = {childList: true};

var element = document.querySelector('body');

 

var seleniumObserver = window.document.MutationObserver || {};

 

window.seleniumObserver = {

observer: new MutationObserver(function (mutationsList) {

for (let mutation of mutationsList) {

if(mutation.addedNodes.length >= 1) {

                        seleniumObserver.occurred = true;

                        seleniumObserver.observer.disconnect();

 

}

}

}),

occurred: false

};

 

Getting closer. When the event happens, the script will flip the 'occurred' variable from ‘false’ to 'true'. The automation script polls the DOM until it flips and it knows the modal is displayed. Fine for adding the modal, but what about when the modal is removed? Test for a similar removed node:

 

var config = {childList: true};

var element = document.querySelector('body');

 

var seleniumObserver = window.document.MutationObserver || {};

 

window.seleniumObserver = {

       observer: new MutationObserver(function (mutationsList) {

              for (let mutation of mutationsList) {

                     if (mutation.addedNodes.length >= 1) {

                           seleniumObserver.added = true;                       

                     }

                     if (mutation.removedNodes.length >= 1) {

                           if(seleniumObserver.added) {

                                  seleniumObserver.occured = true;

                                  seleniumObserver.observer.disconnect();

                           }

                     }

              }

       }),

       occurred: false,

       added: false

};

 

The observer now watches for a node added immediate under ‘body’ and when that happens, it flips the variable ‘added’ to ‘true’ and watches for any removed node (also directly under the ‘body’ node) after the added node was detected. Since the only node that is added and then removed that exists under ‘body’ is the modal, the observer will not set the flag ‘occurred’ to ‘true’ until both events happen.

 

Time to construct a class for the MutationObserver observers and, since the watchers can only observe a single node, that node will be used in the constructor:

 

using System;

using OpenQA.Selenium;

using OpenQA.Selenium.Support.UI;

 

namespace Framework.Utilities

{

public class WatchElement

{

private static IWebDriver _driver;

private readonly string _elementSelector;

 

public WatchElement(Driver driver, string elementSelector)

{

this._driver = driver;

this._elementSelector = elementSelector;

}

}

}

 

Add the method for injecting the script into the DOM (note the javascript is placed in anonymous function before injecting):

 

public void WatchForModal() {

       var moScript = @”(function (elementSel){      

       var seleniumObserver = window.document.MutationObserver || {};    

       window.seleniumObserver = {

              observer: new MutationObserver(function (mutationsList) {

                     for (let mutation of mutationsList) {

                           if (mutation.addedNodes.length >= 1) {

                                  seleniumObserver.added = true;                       

                           }

                           if (mutation.removedNodes.length >= 1) {

                                  if(seleniumObserver.added) {

                                          seleniumObserver.occured = true;

                                         seleniumObserver.observer.disconnect();

                                  }

                           }

                     }

              }),

              occurred: false,

              added: false

       };

       var config = {childList: true};

       var element = document.querySelector(elementSel);

       window.seleniumObserver.observer.observe(element, config);})(arguments[0]);";  

 

IJavaScriptExecutor jsExecutor = (IJavaScriptExecutor)_driver;

jsExecutor.ExecuteScript(moScript, _elementSelector);

}

The element selector used to instaniate the class is passed to the function as an argument defined in the script and used in the ExecuteScript method.

 

Now, when the class is instantiated the selector of the node being watched is used as a constructor argument. The selector for the node to watch is used as an argument to the javascript anonymous function. Now, when the method is called it will inject the MutationObserver into the DOM and monitor any changes. Once the modal is removed, the ‘occurred’ flag is set to true. Next method needed is for polling the DOM waiting for the event flag set to ‘true’.

 

public bool WaitForChange()

{

var results = defaultWait.Until( driver1 =>               ((IJavaScriptExecutor)_driver).ExecuteScript("returnwindow.seleniumObserver.occurred;").Equals(true));

return results;

}

 

The MutationObserver class now contains a method to inject the observer into the DOM and another method to poll and wait for the end of the event. It is time to test for the event in Selenium.

 

The steps for this part of the automation are:

1.       Currently at the page where the modal will be displayed

2.       Execute the action that will cause the event (click on the ‘Email’ button)

3.       Check for the end of the event

4.       Continue with test

 

The test script might appear as follows:

 

var watcher = new Watcher(driver, nodeToWatchSelector);

watcher.WatchForModal();

var emailBtn = driver.FindElement(By.CssSelector(buttonSelector));

emailBtn.Click();

if(WaitForChange()) {

<continue with test>

}

else {

<test failed>

}

 

And the script is stuck at ‘watcher.WatchForModal’. It never returns from the anonymous function since it is a callback. But, I have no need to listen for a callback. I can encapsulate the method and let the compiler deal with it as long as it returns control to click the button for emails. To collect the three steps: watch, execute, wait – I need another method in the Watcher class:

 

        public bool AffectWith(Action watcher, Action action)

        {

            watcher();

            action();

            return WaitForChange();

        }

 

 Watcher is an Action (delegate so it becomes encapsulated) and so is the button click. Each will execute and control returns back to the method for the next step until it has to wait for the change.

 

Now my selenium script looks like this:

 

var watcher = new Watcher(driver, nodeToWatchSelector);

var emailBtn = driver.FindElement(By.CssSelector(buttonSelector));

Action clickButton = () => emailBtn.Click();

if(watcher.AffectWith(watcher.WatchForModal, clickButton) {

  <detected modal is gone, keep testing>

}

else { <problems with modal> }

 

 

The automated test will navigate to the email page, set up a watcher action and then a button action. After that it passes them to the watcher class and executes. The Watcher injects the MutationObserver, clicks the button and then monitors the observer until it either flips to ‘true’ or times out. Once ‘true’, the automation continues on to count all the emails on the page.

MutatationObservers are handy for detecting changes in the DOM node hierarchy, changes to any attribute and changes to the node text.  They work well since MutationObservers are executed the same as a ‘Promise’, they are a microtask and are executed before the next task in the DOM. By watching for the ‘flip to true’, I also know that any ‘async’ Angular call has completed as well.

Reply all
Reply to author
Forward
0 new messages