Skip to content

Commit

Permalink
Add documentation for multi-browser testing
Browse files Browse the repository at this point in the history
  • Loading branch information
milankinen committed Dec 25, 2021
1 parent 292bcbc commit f1f7f40
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 5 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ Generated API docs and guides are also available in **[cljdoc.org](https://cljdo
* [Asynchrony](https://cljdoc.org/d/cuic/cuic/CURRENT/doc/usage/asynchrony)
* [Tests and fixtures](https://cljdoc.org/d/cuic/cuic/CURRENT/doc/usage/tests-and-fixtures)
* [REPL](https://cljdoc.org/d/cuic/cuic/CURRENT/doc/usage/repl)
* [Cookbook](https://cljdoc.org/d/cuic/cuic/CURRENT/doc/cookbook)
* [Multi-browser testing](https://cljdoc.org/d/cuic/cuic/CURRENT/doc/cookbook/multi-browser-testing)

## Similar projects

Expand Down
10 changes: 6 additions & 4 deletions doc/chrome.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ you must configure your logging library implementation to include
`cuic.chrome` (or `cuic`) logger with the desired logging level:

* `FATAL` - unexpected non-recoverable errors
* `ERROR` - unexpceted but recoverable errors
* `ERROR` - unexpected but recoverable errors
* `DEBUG` - browser lifecycle events
* `TRACE` - stdout and stderr of the browser process

Expand All @@ -63,11 +63,13 @@ you must configure your logging library implementation to include
Once you've obtained a Chrome instance, you have three different options
to use it in `cuic.core` functions:

1. Pass it directly to each function invocation
2. Use dynamic `cuic.core/*browser*` variable and Clojure's `binding` macro.
1. Pass it directly to each function invocation (`cuic`'s query functions
allow defining the browser explicitly, binding the retrieved element to
the used browser. See *"Multi-browser testing"* for more details).
3. Use dynamic `cuic.core/*browser*` variable and Clojure's `binding` macro.
This is the recommended option for test runs (see [testing guide](./tests.md)
for more details).
3. Set it globally as the default browser with [cuic.core/set-browser!].
4. Set it globally as the default browser with [cuic.core/set-browser!].
This is the recommended option for REPL (see [REPL setup](./repl.md) for
more details).

Expand Down
4 changes: 3 additions & 1 deletion doc/cljdoc.edn
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
["Interactions" {:file "doc/interactions.md"}]
["Asynchrony" {:file "doc/async.md"}]
["Tests and fixtures" {:file "doc/tests.md"}]
["REPL" {:file "doc/repl.md"}]]]}
["REPL" {:file "doc/repl.md"}]]
["Cookbook" {:file "doc/cookbook.md"}
["Multi-browser testing" {:file "doc/multibrowser.md"}]]]}
10 changes: 10 additions & 0 deletions doc/cookbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Cookbook - tips and tricks for `cuic`

`cuic` is rather low-level library containing only primitives that can
be used as building blocks for more complex use cases. The following
articles contain some snippets for the different real-life use cases
I've encountered and how I've solved them with `cuic` so far.

Note that these snippets are not the only or the "right" way to solve
these use cases, but rather examples that you can use as a base for
your own solutions!
220 changes: 220 additions & 0 deletions doc/multibrowser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
## Multi-browser testing

Let's assume that we have a chat application, and we want to test that
the messages are readable by another users in real-time after they've
been sent. This is not possible to test using single browser only because
we'd potentially need to refresh the browser with another user credentials
after we've sent a message. So let's create a multi-browser setup!

### Test fixture setup for multiple browsers

Multi-browser test fixture setup is a bit more complex than setup with a single
browser, because we need a symbol for the browser instances that are accessible
in our test cases to "switch" the browser. So, whereas normally you could use
`browser-test-fixture`, for multi-browser tests, you must define the test fixture
by yourself. Fortunately `cuic` uses separate profiles for each opened browser
instance so that they don't for example share sessions, thus can be used to
simulate different simultaneous users.

Here an example implementation that I've used to launch multiple browsers for
different users:

```clj
;; This dynamic variable contains the opened browser instances
(def ^:private ^:dynamic *browsers* {})

(defn launch-test-chrome [options]
;; Just copying browser-test-fixture browser setup here!
(let [headless (if-some [headless (:headless options)]
headless
(not= "false" (System/getProperty "cuic.headless")))]
(chrome/launch (assoc options :headless headless))))

(defn get-browser [user]
(or (get *browsers* user)
(throw (RuntimeException. (str "Browser not found for user " user)))))

(defn init-user-session [browser user]
;; Assuming that c/*base-url* is set, we could also run e.g. any
;; login steps here if application requires login. Also note that
;; we need to explicitly define the browser for c/goto because we
;; haven't set any default browser yet!
(c/goto "/" {:browser browser}))

(defn multibrowser-fixture
([users options]
{:pre [(seqable? users)
(every? keyword? users)]}
(if-let [user (first users)]
(fn [t]
(with-open [browser (launch-test-chrome options)]
(init-user-session browser user)
(binding [*browsers* (assoc *browsers* user browser)]
((multibrowser-fixture (next users) options) t))))
(fn [t] (t))))
([users]
(multibrowser-fixture users {})))

;; Usage in test suites
(use-fixtures
:once
(my-server-fixture '...)
(multibrowser-fixture [:bob :alice]))
```

### Switching browser in tests

`cuic` itself doesn't care how many browsers are opened for tests. The API
and functions are designed so that they're decoupled from the browser
management, and it's up to your preferences to choose how to manage the
browser switching.

1. Rebind `c/*browser*` locally
2. Pass the browser explicitly for each function call

I personally prefer option 1 because it allows to keep the testing dsl
clean of boilerplate but still be explicit in the test cases. The basic
idea is that I write functions like I'd write them for single browser:

```clj
(defn messages []
(->> (c/query {:by ".message" :in (c/find "#messages")})
(map c/inner-text)))

(defn status-text []
(-> (c/find "#status-area")
(c/inner-text)))

(defn write-message [text]
(doto (c/find "#new-message")
(c/clear-text)
(c/fill text))
(c/press 'Enter))

(defn send-message []
(doto (c/find "#send")
(c/click)))

(deftest* chat-message-visibility-test
(binding [c/*browser* (get-browser :alice)]
(is* (= "" (status-text)))
(is* (= [] (messages))))
(binding [c/*browser* (get-browser :bob)]
(write-message "tsers!"))
(binding [c/*browser* (get-browser :alice)]
(is* (= "Bob is writing..." (status-text)))
(is* (= [] (messages))))
(binding [c/*browser* (get-browser :bob)]
(send-message))
(binding [c/*browser* (get-browser :alice)]
(is* (= "" (status-text)))
(is* (= ["tsers!"] (messages)))))
```

If you know that the majority of tests require multiple browsers, you can
also pass the browser as a parameter to avoid `binding` boilerplate. Note
that the browser is required only for query functions (like `c/find` and
`c/query`) and elements returned by those functions are bound to the browser
that was used to get them.

```clj
(defn messages [browser]
(let [container (c/find {:by "#message"
:in browser})]
(->> (c/query {:by ".message" :in container})
(map c/inner-text))))

(defn status-text [browser]
(-> (c/find {:by "#status-area" :in browser})
(c/inner-text)))

(defn write-message [browser text]
(doto (c/find {:by "#new-message" :in browser})
(c/clear-text)
(c/fill text))
(c/press 'Enter))

(defn send-message [browser]
(doto (c/find {:by "#send" :in browser})
(c/click)))

(deftest* chat-message-visibility-test
(let [bob (get-browser :bob)
alice (get-browser :alice)]
(is* (= "" (status-text alice)))
(is* (= [] (messages alice)))
(write-message bob "tsers!")
(is* (= "Bob is writing..." (status-text alice)))
(is* (= [] (messages alice)))
(send-message bob)
(is* (= "" (status-text alice)))
(is* (= ["tsers!"] (messages alice)))))
```

### REPL with multiple browsers

Multi-browser setup with REPL is pretty straightforward: we can reuse our
`launch-test-chrome` and `init-user-session` to create and initialize browser
session and then use clojure's `alter-var-root` to store them into our
`*browsers*` symbol:

```clj
(defn launch-browsers! [users]
;; Terminating previous browsers before launching new ones
(doseq [[_ browser] *browsers*]
(try
(chrome/terminate browser)
(catch Exception e
(.printStackTrace e))))
;; Then launch new browsers, one by one
(alter-var-root #'*browsers* (constantly {}))
(doseq [user users]
(let [browser (launch-test-chrome {:headless false})]
(init-user-session browser user)
(alter-var-root #'*browsers* assoc user browser))))
```

The main problem with REPL in multi-browser setup is how to easily
switch the browser. If you've implemented browser switching by rebinding
the `c/*browser*` symbol, you can use `(c/set-browser!)` in REPL every
time you want to switch the default browser. In such cases I've added the
following comment form into my test case namespaces:

```clj
(comment
;; Evaluate this when you start writing/debugging your test(s)
(launch-browsers! [:bob :alice])

;; Evaluate this every time when you want to switch the browser to "Bob"
(c/set-browser! (get-browser :bob))
;; Evaluate this every time when you want to switch the browser to "Alice"
(c/set-browser! (get-browser :alice))
-)
```

If you've used explicit browser instance passing to implement the browser
switching, the setup is even simpler:

```clj
(comment
;; Evaluate this when you start writing/debugging your test(s)
(launch-browsers! [:bob :alice])

;; Evaluate this every time when you want to switch the browser to "Bob"
(def browser (get-browser :bob))
;; Evaluate this every time when you want to switch the browser to "Alice"
(def browser (get-browser :alice))
-)
```

Note that you can use the same `browser` symbol that you're using in your test
functions. By this, you can even evaluate individual forms inside your functions
as well, using to the latest "switched" browser!

### Closing words

As you can see, testing with multiple browsers isn't much more difficult compared
to the normal single browser testing. Yes, it requires some extra setup and utilities
for the browser management, but when you get them up and running, it's pretty much
the same. And because the browser management and switching is built by you, you can
modify and tailor it to fit your needs as much as you like.

0 comments on commit f1f7f40

Please sign in to comment.