07 Aug 2019 Angular 7+ and A/B Testing
Disclaimer: this approach won’t be suitable for everybody, please factor in your particular requirements before using it.
Towards the end of 2018, our client started to move our codebase from AngularJS to Angular 7+ (now 8). While this, in itself, is a great move, it completely broke our A/B testing capabilities. (What’s A/B Testing?) Implementing deep changes to our Angular applications would be much more challenging. The methods we used to amend code on bootstrap of AngularJS applications are no longer available in Angular2+. If you are interested in A/B testing in AngularJS applications, you can read about our previous approach and Adobe setup.
While some A/B testing can, and should, be done from the backend, this article will focus on the frontend approach.
While thinking about new ways to build A/B-tests, we had to consider:
- Needs to be able to interact with any given application (ideally language agnostic)
- Should not be interruptive to other teams working on business as usual
- Not bound to release cycles of the various application
- Utilisation of Adobe Target as decision engine
- Continuous delivery
- Writing sharable, quality code, faster to implement in the actual application if test was successful
- As little as possible support and groundwork in existing application
We favoured solutions using an Angular environment for easily transitioning between projects (which use predominantly Angular).
We shed a tear or two for the old methods of template-cache injection and decorators (AngularJS), which Angular 2+ (8.0. as of writing this) does not have a concept of. After reading some articles about Angular Elements, we decided to have a crack at using them, and try to find our limitations with this tech. (Angular Elements are Angular components packaged as web components, a web standard for defining new HTML elements in a framework-agnostic way.)
The advertising sounded great:
- creating custom Dom elements utilising the power of Angular
- language agnostic (can be used in any app as it will be treated as a native DOM element)
- deployable as standalone micro application
Advantages looked great:
- Angular ✓
- usable in combination with any app ✓
- reusable code ✓
- less wiring up (as we thought) ✓
- more easily testable code (yay unit tests) ✓
- faster development ✓
- may require custom event emitter, in tested apps, for more complex interactions 😐
But how to get started?
Each A/B test contains of at least one variation to an existing part of the website. While we usually should not need any code for the control (variation A), the fun begins with variation B (also called an experiment). While occasionally multiple variations can be tested (A/B/C…) it is only advisable with a high traffic volume for statistical reasons. Each variation potentially needs to interact with multiple different apps in a variety of versions. Each part of a variation can consist of:
- isolated new features (eg: a new navigational component to help a user to find specific information faster)
- change of behaviour and look of an existing part (eg: replacing parts or whole journey from arrival of user to sale of product)
- complete new pages
- a combination of all the above
- possibly multiple variations of the same test.
The new approach should be able to handle all the above.
We decided to use a monorepo which will ultimately hold all the A/B tests, neatly sorted.
We decided to have all our tests sorted by test ID. We also needed to consider that an A/B test could potentially contain multiple parts (not variations, which we will get to later). Each part should be able to be deployed and used either in conjunction with other parts, or as standalone. (eg: one part is adding a new component to a part of the website controlled by an Angular application, while in a different section, a different component needs to complement a Drupal application)
Therefore, we picked a structure as follows:
test-collection apps dbopt-123 header-element footer-element dbopt-456 header-element footer-element
First problem – Angular CLI does not have this structure out of the box, but NX comes to the rescue!
npx -p @nrwl/schematics create-nx-workspace test-collection
Great but now we get a not valid name error???
CLI ERROR: 'dbobt-123' is not valid. New project names must start with a letter, and must contain only alphanumeric characters or dashes. When adding a dash the segment after the dash must also start with a letter.
Ah, the CLI does some magic here and basically apps are named dbobt-123-header-element. Therefore, hacky hack and because we are lazy we just add
exp before the number (e.g. dbopt-exp123)… all good now. Let’s get the magic of the CLI work done. (Here is a discussion of the issue on GitHub).
Also to differentiate different A/B test variations, we add the variation at the end (e.g. dbopt-exp123b)
Now down to the basics. To create Angular Elements, we still require some more packages:
ng add @angular/elements ng add @webcomponents/custom-elements
We will add other dependencies as required.
Creating Angular Elements
To create an Angular Element from scratch we need to add a few things.
Setting the stage
As the index.html in each Element is only used for local development, we replace the default content with something like this:
We also need to make some changes in the main.ts, where some of the magic is happening. We add a method which will place our custom element in a predetermined place in the DOM. The SettingUp Class contains just a few scripts help to create a new DOM element, finding the location(s), positioning and other “creative” ways to interact with the outside of our little Angular Element for many different scenarios. This allows anyone working with that repo to focus on creative solutions.
We now also need to add the tooling to tsconfig.json and tsconfig.app.json within the newly created element
First act – Angular Element but different
We need to add a couple of polyfills to the polyfills.ts, for browser support.
For sanity reasons it is advisable to exclude ZoneJs and fall back to other change detection strategies. Zone is a global on the window object. No issue if all applications are using the same version. However, if that is not the case due to progressively upgrading applications and A/B tests, Zone will be overridden with each different Zone version depending on loading order. (A version conflict of ZoneJs will throw an error in the console of the browser stating that the Zone awareness promise has been overwritten)
If you exclude Zone than you need to do it in the main.ts as well
In the app.component.ts we now we need to specify some View encapsulation. If you require IE11 support, you must use Emulated. If IE is not a concern the better option is using the Native option. (ViewEncapsulation.Native uses the shadow DOM instead of just name-spacing the element)
We also rename the class AppComponent to something more meaningful, as we may need multiple of these components to work together
Every new Angular Element requires some work on the app.module.ts as well.
- Import the `BrowserModule` from ‘@angular/platform-browser’ and declare it in the imports
- Import `createCustomElement` from @angular/elements
Now in the NgModule we need to state as well that this will be an entryComponent and in the standard AppModule class, we add a constructor and bootstrap it.
So far so good, let’s just add a basic test for it
However for your Angular unit tests you may still need ZoneJs.
Second act – Webpack
We would also like to use some custom Webpack magic. So we need to replace the builder of the Element in the angular.json for build and serve
Since these steps are required for every new Angular Element, we create a script that does all the above for us. A small shell script helps us to chain our little boilerplate script to an `ng generate app`.
Since we also have already predefined our standard packages we do not want to run an install on app generation. So using –skip-install (appears to be removed in Angular 8+) and –skip-package-json prevents our repo from constantly trying to add packages which we do not want/need at this stage.
Great! Now we add our script to the package.json so we can get our Element setup with one command
Now we are ready to build cool new things.
Third act – A/B testing joins the party
We just need to remember that there are 2 locations for styles and scripts.
Styles and scripts within our new module are nicely contained and bundled up in our Js distribution file, while global styles and scripts are added to the HTML bundle separately to enable DOM Manipulation as soon Adobe delivers the offer code.
Adding a script to remove existing A/B Tests from the repo might be an advantage as well, so that you do not need to remove the entries manually from various locations such as angular.json and nx.json. (this is pure vanity and supports a more free approach to create and destroy new elements or whole A/B tests on a whim)
3.1 Build it
While the standard `ng build` is quite smart, we need more.
Here we need to first understand how a test gets delivered. Adobe Target will deliver HTML to the page based on information which let us target specific type of users (same as for personalisation). Within this HTML code we need to deliver everything we need for the A/B test. However, Adobe limits us to 256kb of code (which may be an issue for tests where the combined code exceeds this limit. Also we need to considter that with more code delivered via Adobe, Adobe Target may timeout on slow internet connections). Having polyfills already available on the page before loading Adobe helps to reduce the bundle size. Until Angular Ivy helps to reduce bundle sizes significantly, larger distribution files will have to be loaded from S3. Adding a simple script tag to load the distribution file from S3 to the HTML offer will do the trick.
We also need to consider 2 parts for each test
- styles and code manipulating the DOM outside of our new element and ideally run as soon Adobe decides the test needs to run
- self-contained new component (adding or replacing parts of the page)
While loading larger tests from S3, the first part can already be executed before the second part has even arrived.
To cater for this quirk we assemble our HTML as follows:
- We combine our JS files in our distribution folder (namely main.js, polyfills.js, runtime.js) to one file, let’s call it
dbopt-exp123b-standalone.bundle.js (add cache busting at your own convenience) and add this in a script tag to the HTML bundle (use webpack or your own custom script to do so).
- Additionally we are adding the content of the styles.css with a style tag to our html.bundle
That way we have styles which can manipulate the page even before we load our custom element. (In case you want/need to remove/hide some other parts of the page not part of you custom element.)
3.2 It’s not working!
Now if you would try to run the element if will encounter a couple issues
Failed to construct ‘HTMLElement’: Please use the ‘new’ operator, this DOM object constructor cannot be called as a function
This indicates that the element register you are using is using an older version which has this issue. To mitigate, upgrade your document-register-element from ^1.7.2 to 1.8. in your package.json.
Also, if you are working with many creative people you may want to consider validating the names for the custom element in your automation scripts.
By default, webpack uses a global variable webpackJsonp. When we have multiple custom elements on one page this creates an issue of running the bootstrapping of the first element twice and erroring out, subsequently not running any other element.
Error message you will see:
ERROR DOMException: Failed to execute 'define' on 'CustomElementRegistry': this name has already been used with this registry
This appears to be a known issue (Github issue discussion). To avoid this we need to tell Webpack to use a more unique name.
Great! With that in place, we can now have multiple Elements on the same page.
Currently Jenkins is our tool of choice for our build pipeline. A little trigger script triggers all required builds (the ones where code has changed) out of our monorepo , so that we can build each individual element (which is already like a standalone app on its own) in parallel.
At the end of the build pipeline the distribution file gets uploaded to an S3 bucket and the HTML bundle replaces the content in our Adobe HTML offer form correlating to the activity of that A/B test.
By having these 2 steps part of the build pipeline, no manual update of Adobe HTML Offer is required and all changes will be automatically deployed (if all tests pass).
For safety, the build job will first check Adobe for the status of the test, before updating current code in the Adobe Offer and will fail this step if the test is live.
In case of and deployment/build pipeline failure a message is sent to the dev via Slack.
For safety reasons, the final switch to turn tests live will be done from the Adobe UI and can be considered a feature toggle.
Final act – Semi-local what?
To have all elements of an A/B test served out together for local development, we set up a blank application, which incorporates all individual elements.
Each individual element of an A/B test will be served out over an individual port, while the common app pulls these one in over a common port. For that we let the main.ts create an index.html with script tags loading the distribution files from each individual port of the individual elements.
With a little shell script which spins up a port for each individual element within a test folder, we store the port numbers in a ports.ts file which contains all the sub-ports used during the ng-serve command.
in main.ts of our combination app we add a bit so that it appends all the required scripts to its index.html and executes them. (Be aware with Angular 8 “es2015-polyfills.js” will change to “polyfills-es5.js”.)
In Adobe we set up a blank activity which is loaded on every page, where we adding a unique query parameter (“yogi” in the case seen below).
In the offer linked to this activity we add script tags to load our distribution files from our common port (can be common port for all elements within that test or individual port for individual element). (Be aware with Angular 8 “es2015-polyfills.js” will change to “polyfills-es5.js”.)
As long Adobe is configured to work also on your staging environment, and thanks to our placement script in the main.ts, each element tries to find its intended location on the page loaded with our query parameter (eg: https://example.com.au/?yogi ).
The URL parameter is used solely for testing purposes. Running in production it should be replaced with the proper audience in Adobe Target.
Note that this method only works on Chrome and Firefox due to mixed origin policy.
To enable our new components to interact with other parts of the website (for deep logic and behaviour changes) they must communicate via custom DOM events which may or may not already be used by your application.
There are still many improvements to be done and in progress for this. We are constantly adding new tooling as we go along.
After using this approach for several months, we are quite pleased with its performance. We have every step automated, and only need to know a couple of npm commands (beside the git commands), enabling rapid development of new features.
If DOM manipulations on dynamic and SPA templates are required, it can be challenging, but simple features can be done in as little as 2 minutes from start of development to live in production, thanks to automation.