Web app quality beyond testing

Gleb aka @bahmutov

CycleConf2017

Angular1, HyperApp,

Vue, Cycle, Node

KENSHO

Technology that brings transparency to complex systems

Harvard Sq, WTC NYC

  • Preaching Cycle.js
  • Ranting
  • Exploratory Testing
  • Dreaming...

Me: Have you heard about Cycle.js?

They: No

Me: It is not about popularity

They: Ok, what is it about?

Streams!

You control streams!

Me: The very first Cycle.js example has reactive streams.

Me: other frameworks have it as advanced add-on

Me: like Angular2 "async"

@Component({
  selector: 'async-promise-pipe',
  template: `<div>
    <code>promise|async</code>: 
    <button (click)="clicked()">
        {{ arrived ? 'Reset' : 'Resolve' }}
    </button>
    <span>Wait for it... {{ greeting | async }}</span>
  </div>`
})
export class AsyncPromisePipeComponent {...}

They: What's so great about streams?

Me: It makes the code simpler

// child component
Vue.component('person', {
  props: ['first']
})
// parent template
<div>
  <person v-bind:first="manager.ownName"></person>
</div>
// parent "manager.ownName"
//   --- undefined ---- Joe --->
// child "first"
//   --- undefined ---- Joe --->

Property binding in Vue

// child component
Vue.component('person', {
  props: ['first'],
  data: function () {
    return { name: this.first }
  }
})
// parent template
<div>
  <person v-bind:first="manager.ownName"></person>
</div>
// parent "manager.ownName" --- undefined ---- Joe --->
// child "first"            --- undefined ---- Joe --->
// child "name"             --- undefined --->

Want to modify prop in component?

hmm

// child component
Vue.component('person', {
  props: ['first'],
  computed: {
    name: function () {
      return this.first || 'Unknown'
    }
  }
})
// parent template
<div>
  <person v-bind:first="manager.ownName"></person>
</div>
// parent "manager.ownName" --- undefined ---- Joe --->
// child "first"            --- undefined ---- Joe --->
// child "name"             --- Unknown ------ Joe --->

One-way property binding with validation

Vue.js:

v-bind

v-once

prop

data

computed

parent

component

But that's not all ...

<!-- this passes down a plain string "1" -->
<comp some-prop="1"></comp>
<!-- this passes down an actual number -->
<comp v-bind:some-prop="1"></comp>
// Angular 1 props
app.directive('directiveName', function(){
   return {
      scope: {
         name: '@' // or '=' or '&'
      }
   }
})
// parent
const Rx = require('rxjs/Rx')
const parent = {
  manager: {
    ownName: Rx.Observable.of(undefined, 'Joe', 'Mary')
  }
}
// child
function person(parent) {
  const {manager: {ownName}} = parent
  ownName
    .first(s => s) // <= right here!
    .subscribe((name) => {
      console.log('person got new name', name)
    })
}

Passing streams

const child = person(parent)
// manager.name --- undefined --- Joe --- Mary ---|>
// child        ----------------- Joe -|>

Passing streams

Common time or event logic can be handled easier with streams

first value

distinct valid value

Me: passing component properties in Cycle means passing streams!

function Component({props}) {
  // ...
  return sinks;
}
const c = Component({
  props: () => xs.of({
    label: 'Weight', unit: 'kg', 
    min: 40, value: 70, max: 140
  })
})

Me: component returns a stream to communicate with its parent

import {prop} from 'ramda'
function Component({props}) {
  // ...
  const sinks = {
    DOM: vdom$,
    value: props$.map(prop('value'))
  }
  return sinks
}

props$

--- 10 --- 20 --- 20 --->

values$

<--- 10 --- 20 --- 20 ---

props$.map(I)

Me: component returns a stream to communicate with its parent

They: All that looks so weird

props$

--- 10 --- 20 --- 20 --->

values$

<--- 10 --- 20 --- 20 ---

Me: it gets weird when component has its own "UI cycle"

DOM$

--- 10 --- 20 --- 20 --->

<--- 10 --- 20 --- 20 ---

Note: this time diagram is inconsistent 😐

props$

--- 10 --- 20 --- 20 --->

values$

Aside: what should we test for this system?

DOM$

<--- 10 --- 20 --- 20 ---

props$

--- 10 --- 20 --- 20 --->

values$

DOM$

initial value only

Aside: what should we test for this system?

<--- 10 --- 20 --- 20 ---

props$

--- 10 --- 20 --- 20 --->

values$

DOM$

all input/output values

Aside: what should we test for this system?

<--- 10 --- 20 --- 20 ---

props$

--- 10 --- 20 --- 20 --->

values$

DOM$

input/output + DOM$

Aside: what should we test for this system?

<--- 10 --- 20 --- 20 ---

props$

--- 10 --- 20 --- 20 --->

values$

DOM$

input/output + UI

Aside: what should we test for this system?

<--- 10 --- 20 --- 20 ---

Enter human user

πŸ€”

User

App

props$

--- {value:50, label:'kg'} --->

values$

<--- 50 ---

Merge props$ with slide value

newValue$

<--- 50 ---

kb

πŸ€”

props$

values$

<--- 70 ---

User moves the slider

newValue$

<--- 70 ---

70

kb

😟

props$

--- {value:100, label:'lb'} --->

values$

<--- 155 ---

New props are passed from parent

newValue$

155

lb

<--- 155 ---

😟

ignored

function Component({props}) {
  // combine latest slider value newValue$ 
  // with properties passed in props
  const state$ = props
    .map(props => newValue$
      .map(val => ({
        label: props.label,
        unit: props.unit,
        min: props.min,
        value: val,
        max: props.max
      }))
      .startWith(props)
    )
    .flatten()
    .remember()
  // ...
  return sinks
}

They: You said "simple"!

Ramda is your friend

var stream = 
  .filter(R.prop('foo'))
  .map(R.lens)
  ...

Ramda is your friend

Lots of ways to avoid writing your own "small" pure functions

var stream = xs.periodic(1000)
  .filter(i => i % 2 === 0)
  .map(i => i * i)
  .endWhen(xs.periodic(5000).take(1))

Ramda is your friend

import {filter, map, pipe} from 'ramda'
const isEven = i => i % 2 === 0
const square = i => i * i
const evenSquares = pipe(
  filter(isEven),
  map(square)
)
const stream = evenSquares(xs.periodic(1000))
  .endWhen(xs.periodic(5000).take(1))

They: Ok, can I try it myself?

They: Ok, can I try it myself?

They: Ok, can I try it myself?

Me: use create-cycle-app

npm install --global create-cycle-app
create-cycle-app example --flavor cycle-scripts-one-fits-all
cd example
npm start &
open localhost:8080

TypeScript

Start with TypeScript

import xs, { Stream } from 'xstream';
import { VNode } from '@cycle/dom';
import { Sources, Sinks } from './interfaces';
export function App(sources : Sources) : Sinks
{
  const vdom$ : Stream<VNode> = xs.of(
    <div>My Awesome Cycle.js app</div>
  );

  return {
    DOM: vdom$
  };
}

Β I don't use type system like Typescript - it does not let me hack things together!

Trying TypeScript

Refactoring is FAST

They: Can you just give me Cycle.js CDN script?

Cycle.js

They: Can you just give me Angular CDN script?

<script src="cdn/angular.js"></script>
<div ng-app="myApp">
  <ul ng-controller="Todo">
    <li ng-repeat="todo in todoList">
      {{todo.label}}
    </li>
  </ul>
</div>
<script>
  angular.module('myApp', [])
    .controller('Todo', ...)
  // BOOM App is working!
</script>
<script src="https://unpkg.com/@cycle/run@3.1.0/dist/cycle-run.js"></script>
<script src="https://unpkg.com/@cycle/dom@16.0.0/dist/cycle-dom.js"></script>
<script>
const {run} = Cycle
const {div, label, input, hr, h1, makeDOMDriver} = CycleDOM
// rest of the code
</script>
Uncaught TypeError: Cannot read property 'default' of undefined
    at makeSinkProxies (cycle-run.js:33)
sinkProxies[name_1] = xstream_1.default.createWithMemory();
<script src="https://unpkg.com/xstream@10.3.0/dist/xstream.js"></script>
<script src="https://unpkg.com/@cycle/run@3.1.0/dist/cycle-run.js"></script>
<script src="https://unpkg.com/@cycle/dom@16.0.0/dist/cycle-dom.js"></script>

Me: fine, I will run webpack for you

<script src="https://web-packing.com/@cycle/run&@cycle/dom">
const {run} = packs.run
const {div, label, input, hr, makeDOMDriver} = packs.dom

Rollup/Browserify as a service

@lucamezzalira

@rich_harris

AWS Lambda to run webpack

Immutable webpack bundles on demand

The Magician's Hat

Making steps easier

Example: Zeit.co

  • now

  • hyper terminal

  • next.js

They: Can Cycle work with my web app?

Me: I will write a driver for you!

Cycle.js

Rule of πŸ‘: Can I drive your app from the DevTools?

// Angular 1
$(el).scope().foo = 'bar'
$(el).scope().$apply()
// Vue
vue = new Vue(...)
vue.$data.foo = 'bar'
// Cycle?

Is your Cycle App like this bottled garden?

Real systems have brass ball valves

Rx.filter(_ => window.paused)

Real systems have PVC plugs

Real systems allow easier access into their workings

// counter example + "PVC Plug"
const devToolsProducer = {
  start: listener => {
    window.cyclePlug = listener.next.bind(listener)
  },
  stop: () => {
    delete window.cyclePlug
  }
}
const devTools$ = xs.create(devToolsProducer)
const action$ = xs.merge(
  sources.DOM.select('.dec').events('click').mapTo(-1),
  sources.DOM.select('.inc').events('click').mapTo(+1),
  devTools$
)

Plugs work great for exploratory testing

Just keep track of all the plugs that you have installed πŸ˜‰

Placing plugs where it matters

xs.merge(a$, b$, ...)

const action$ = xs.merge(
  sources.DOM.select('.dec').events('click').mapTo(-1),
  sources.DOM.select('.inc').events('click').mapTo(+1),
  devTools$
).debug('action')

Useful "output" plugs

action: 1
action: 1
action: -1

Useful "output" plugs

// with https://github.com/pimterry/loglevel
// log.setLevel('debug', true)
const action$ = xs.merge(
  sources.DOM.select('.dec').events('click').mapTo(-1),
  sources.DOM.select('.inc').events('click').mapTo(+1),
  devTools$
).debug(log.debug)

πŸ˜’ Requires page reload πŸ˜’

const action$ = xs.merge(
  sources.DOM.select('.dec').events('click').mapTo(-1),
  sources.DOM.select('.inc').events('click').mapTo(+1),
  devTools$
)
window.action$ = action$

Useful "output" plugs

action$.setDebugListener({next:console.log})

Plug + Debug + conditional breakpoint

= ❀️

Aside: how Cycle influenced my Node testing strategy

emit events

Emitter

specific type of events

test('system', async t => {
  subscribe()
  await startSystem()
  snapshot(events)
})

system under test

Speaking of snapshots

test('complex object', t => {
  const obj = ...
  t.snapshot(obj)
})
Time.assertEqual(
  Time.diagram('---1---3---2--|'),
  expected,
  myCustomCompare
);
Time.run(err => console.error(err));

You can optionally pass a custom comparator function. This is useful if you want to do things like testing your DOM with tools such as html-looks-like.

-- A -- A ---------------->
-----B ----- B ----------->
-----C ----- C ---- C ---->
- D ---------- D -- D ---->
-- E ------- E ----------->

Guitar Hero =

5 Observables

All notes

Chords + Melody

Test your melody

Test Pyramid

Not every test requires opening up the pipe

E2E that is human

πŸ€”

User

App

E2E that is human

πŸ€–

E2E

App

  • Loading
  • Clicking
  • Typing
  • Selecting
  • Finding
  • Checking
  • Waiting
const count$ = action$
  // delay events by 2 seconds
  .map(i => xs.fromPromise(by2seconds(i)))
  .flatten()
  .fold((x, y) => x + y, 0);

Usual E2E runners need delays or run slow in this case ... (Karma, Protractor, Selenium)

beforeEach(() => {
  cy.visit('index.html')
})
it('starts with 0', () => {
  cy.contains('Counter: 0')
})
it('increments counter 3 times', () => {
  cy.contains('Increment').click()
  cy.contains('Counter: 1')
  cy.contains('Increment').click()
  cy.contains('Counter: 2')
  cy.contains('Increment').click()
  cy.contains('Counter: 3')
})

Not Β single "delay" yet it runs in 6 seconds!

"Smart" E2E

  • https://www.cypress.io/ πŸ‘

  • https://github.com/featurist/browser-monkey

  • https://devexpress.github.io/testcafe/

My Testing Trapezoid

οΌ„

οΌ„

Web Application that just works

I'm just using my app, and every error is recorded

Me: If there is a crash - something is wrong

function foo () {
  throw new Error('Oops')
}
foo()
// Catch the exception with window.onerror 
// event listener πŸ˜€

Sometimes even in promises

window.addEventListener('unhandledrejection', e => {
  e.preventDefault();
  console.log('Reason: ' + e.reason);
});
new Promise((resolve, reject) => {
  reject(new Error('oops'))
})
// Catch the unhandled promise rejection πŸ˜€

Observables ...

var xs = xstream.default
xs.periodic(1000)
  .map(x => {
    if (x === 5) {
      throw new Error('Something went wrong')
    }
    return x
  })
  .addListener({
    next: i => console.log(i),
    complete: () => console.log('completed'),
  })
// streams just stops on error 😩

Other frameworks

// Angular 1
var myApp = angular.module('myApp', ['ngRaven'])
// Vue 2
import Vue from 'vue'
import Raven from 'raven-js'
Raven.addPlugin(RavenVue, Vue)

Report individual stream errors

Raven.config('<url>').install()
var xs = xstream.default
xs.periodic(1000)
  .map(x => {
    if (x === 5) {
      throw new Error('Something went wrong')
    }
    return x
  })
  .addListener({
    next: i => console.log(i),
    error: Raven.captureException,
    complete: () => console.log('completed'),
  })

Use debug listener (xstream)

const count$ = action$.fold((x, y) => {
  if (y === 20) {
    throw new Error('Cannot add 20')
  }
  return x + y
}, 0);
vdom$.setDebugListener({
  error: Raven.captureException
})
return {
  DOM: vdom$
};

Presentation Progress

  • Preaching Cycle

  • Cycle + DevTools

  • Testing trapezoid

  • Dreaming

Dream: code by example

Most of my programming is transforming lists of items

Sync problem

const input =  [1, 2, 3, 4]
const output = [2, 3, 4, 5]
// what is f(x)?
const f = x => x + 1
input.map(f) === output

Ramda ⛏ miner

const input =  [1, 2, 3, 4]
const output = [2, 3, 4, 5]
const {?} = require('ramda')

Rambo

const input =  [1, 2, 3, 4]
const output = [2, 3, 4, 5]
const {solve} = require('rambo')
const S = solve(input, ouput)
console.log(S.name)
// "R.add(1)"
S.f(input) // [2, 3, 4, 5]

Stream problem

// observable system behavior
//   --- 1 --- 2 --- 3 --- |->
//   --- 2 --- 3 --- 4 --- |->
// what is the system?

Stream problem

// observable system behavior
//   --- 1 --- 2 --- 3 --- |->
//   --- 2 --------- 4 --- |->
// what is the system?

Stream problem

// observable system behavior
//   --- 1 --- 2 --- |->
//   ------ 2 --- 3 --- |->
// what is the system?

Give me the stream setup to do this!

Final thoughts

  1. Think about casual user
  2. Make exploratory testing easier
  3. Find a great E2E testing tool

-|->

Slides: https://slides.com/bahmutov

Talk to me: @bahmutov

I live at: glebbahmutov.com

Thank you, thank you, thank you