diff --git a/openbis_ng_ui/.eslintrc.js b/openbis_ng_ui/.eslintrc.js
index c7098787dadc161c6a5d87172499eba27b514eb9..43d7806f1e68dcd1ceaaabe842c434f0b926b15f 100644
--- a/openbis_ng_ui/.eslintrc.js
+++ b/openbis_ng_ui/.eslintrc.js
@@ -10,6 +10,7 @@ module.exports = {
   },
   env: {
     browser: true,
+    jest: true,
   },
   extends: [
     'eslint:recommended',
diff --git a/openbis_ng_ui/build.gradle b/openbis_ng_ui/build.gradle
index c438aa54af21a58d3fd7f4867640473811e61a55..cd0bc9066919d66dfe4fbf3c987867ad2a3915aa 100644
--- a/openbis_ng_ui/build.gradle
+++ b/openbis_ng_ui/build.gradle
@@ -30,5 +30,5 @@ node {
   nodeModulesDir = file("${projectDir}")
 }
 
-// npm_run_build.dependsOn npm_run_test
-build.dependsOn npm_run_build
\ No newline at end of file
+npm_run_build.dependsOn npm_run_test
+build.dependsOn npm_run_build
diff --git a/openbis_ng_ui/package.json b/openbis_ng_ui/package.json
index 48f1e7b01fa4edc9729e07478b25fd1f67ad5af2..8a88d49a78fe7e7cda92d9928905b47ed5bcb307 100644
--- a/openbis_ng_ui/package.json
+++ b/openbis_ng_ui/package.json
@@ -20,6 +20,7 @@
     "typeface-roboto": "0.0.54"
   },
   "devDependencies": {
+    "auto-bind": "^2.0.0",
     "babel-core": "6.26.0",
     "babel-eslint": "8.2.5",
     "babel-jest": "^23.6.0",
@@ -39,6 +40,7 @@
     "file-loader": "1.1.11",
     "html-webpack-plugin": "3.2.0",
     "jest": "^23.1.0",
+    "jest-junit": "^6.0.1",
     "raw-loader": "0.5.1",
     "react-hot-loader": "4.3.6",
     "react-loader": "2.4.5",
@@ -55,6 +57,13 @@
     "unit": "jest",
     "lint": "eslint --ext .js,.jsx src test",
     "lint:fix": "eslint --ext .js,.jsx src test --fix",
-    "test": "npm run lint && npm run unit"
+    "test": "jest",
+    "test:watch": "npm test -- --watch"
+  },
+  "jest": {
+    "reporters": [
+      "default",
+      "jest-junit"
+    ]
   }
 }
diff --git a/openbis_ng_ui/src/index.js b/openbis_ng_ui/src/index.js
index 2f67930c4671fbd6952ad1650d743ab23d5fefbb..070d04fadb02747dd133dc808d095f0d28e174cc 100644
--- a/openbis_ng_ui/src/index.js
+++ b/openbis_ng_ui/src/index.js
@@ -1,24 +1,15 @@
-/* eslint-disable */
-import "regenerator-runtime/runtime"
+import 'regenerator-runtime/runtime'
 import React from 'react'
 import ReactDOM from 'react-dom'
-import { createStore, applyMiddleware, compose } from 'redux'
 import { Provider } from 'react-redux'
-import createSagaMiddleware from 'redux-saga'
-import reducer from './reducer/reducer.js'
-import { watchActions } from './reducer/sagas'
 
-const sagaMiddleware = createSagaMiddleware()
-const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
-export const store = createStore(reducer, composeEnhancers(applyMiddleware(sagaMiddleware)))
-
-sagaMiddleware.run(watchActions)
 
 const render = () => {
   const App = require('./components/App.jsx').default
   const WithLogin = require('./components/WithLogin.jsx').default
   const WithLoader = require('./components/WithLoader.jsx').default
   const WithError = require('./components/WithError.jsx').default
+  const store = require('./reducer/middleware.js').default.store
   ReactDOM.render(
     <Provider store = { store }>
       <WithLoader>
@@ -29,7 +20,7 @@ const render = () => {
         </WithError>
       </WithLoader>
     </Provider>,
-    document.getElementById("app")
+    document.getElementById('app')
   )
 }
 
@@ -38,6 +29,6 @@ if (module.hot) {
   module.hot.accept('./reducer/reducer.js', () => {
     const nextRootReducer = require('./reducer/reducer.js').default
     store.replaceReducer(nextRootReducer)
-  });
+  })
 }
 render()
\ No newline at end of file
diff --git a/openbis_ng_ui/src/reducer/actions.test.js b/openbis_ng_ui/src/reducer/actions.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..d97bc3e02d01671d2ba0e6dc1e842a220f2c5403
--- /dev/null
+++ b/openbis_ng_ui/src/reducer/actions.test.js
@@ -0,0 +1,15 @@
+import actions from './actions'
+
+
+// note: This is just an example. It would not make much sense to 
+//       test simple action creators such as this.
+describe('actions', () => {
+  it('should create a login action', () => {
+    const expectedAction = {
+      type: 'LOGIN',
+      username: 'mark.watney',
+      password: 'secret',
+    }
+    expect(actions.login('mark.watney', 'secret')).toEqual(expectedAction)
+  })
+})
diff --git a/openbis_ng_ui/src/reducer/middleware.js b/openbis_ng_ui/src/reducer/middleware.js
new file mode 100644
index 0000000000000000000000000000000000000000..5b90bde21317a525f456c2794e1079d99a73c951
--- /dev/null
+++ b/openbis_ng_ui/src/reducer/middleware.js
@@ -0,0 +1,15 @@
+import { createStore, applyMiddleware, compose } from 'redux'
+import createSagaMiddleware from 'redux-saga'
+import reducer from './reducer.js'
+import { watchActions } from './sagas'
+
+const sagaMiddleware = createSagaMiddleware()
+const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
+export const store = createStore(reducer, composeEnhancers(applyMiddleware(sagaMiddleware)))
+
+sagaMiddleware.run(watchActions)
+store.dispatch({type: 'INIT'})
+
+export default {
+  store: store
+}
diff --git a/openbis_ng_ui/src/reducer/reducer.test.js b/openbis_ng_ui/src/reducer/reducer.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..d454e9debc6f2dbd2e816ec951942c89b49c1292
--- /dev/null
+++ b/openbis_ng_ui/src/reducer/reducer.test.js
@@ -0,0 +1,43 @@
+import actions from './actions'
+import initialState from './initialstate.js'
+import reducer from './reducer.js'
+
+
+describe('reducer', () => {
+  it('should add exception', () => {
+    const exception = { message: 'test error' }
+    const action = actions.error(exception)
+    const expectedExceptions = [exception]
+    const state = reducer(initialState, action)
+    expect(state.exceptions).toEqual(expectedExceptions)
+  })
+})
+
+describe('reducer', () => {
+  it('should add and select entity', () => {
+    const entityPermId = 0
+    const action = actions.selectEntity(entityPermId)
+    const state = reducer(initialState, action)
+    expect(state.openEntities).toEqual({
+      entities: [entityPermId],
+      selectedEntity: entityPermId,
+    })
+  })
+})
+
+describe('reducer', () => {
+  it('should close an entity and select the next open one', () => {
+    const beforeState = Object.assign({}, initialState, {
+      openEntities: {
+        entities: [0, 1, 2],
+        selectedEntity: 1
+      }
+    })
+    const action = actions.closeEntity(1)
+    const state = reducer(beforeState, action)
+    expect(state.openEntities).toEqual({
+      entities: [0, 2],
+      selectedEntity: 2,
+    })
+  })
+})
diff --git a/openbis_ng_ui/src/reducer/sagas.js b/openbis_ng_ui/src/reducer/sagas.js
index fa1bd26523cd665f110b01b8ac17ae738e018bb9..e29c5056996b0d282a362a5456782fbb6ff47965 100644
--- a/openbis_ng_ui/src/reducer/sagas.js
+++ b/openbis_ng_ui/src/reducer/sagas.js
@@ -1,29 +1,59 @@
 import { put, takeEvery, call } from 'redux-saga/effects'
-import openbis from '../services/openbis'
+import Openbis from '../services/openbis'
 import actions from './actions'
 
-// TODO split sagas
+// TODO split sagas when it gets too big
+
+let openbis = new Openbis()
+
+// used only for testing - need to have a new mock for each test
+export function newOpenbis() {
+  openbis = new Openbis()
+  return openbis
+}
+
+export function* watchActions() {
+  yield takeEvery('INIT', init)
+  yield takeEvery('LOGIN', login)
+  yield takeEvery('LOGIN-DONE', loginDone)
+  yield takeEvery('LOGOUT', logout)
+  yield takeEvery('SAVE-ENTITY', saveEntity)
+  yield takeEvery('SET-SPACES', selectSpace)
+  yield takeEvery('EXPAND-NODE', expandNode)
+}
+
+function* handleException(f) {
+  try {
+    yield f()
+  } catch(exception) {
+    yield put(actions.error(exception))
+  }
+}
 
 function* init() {
-  // TODO we want to check if there is an active session here to avoid the login
+  // TODO Check for session token and yield loginDone if valid.
+  //      This can properly be done when we have the session token in a cookie.
+}
+
+function* login(action) {
+  yield handleException(function*() {
+    yield openbis.login(action.username, action.password)
+    yield put(actions.loginDone())
+  })
 }
 
 function* loginDone() {
-  try {
+  yield handleException(function*() {
     const result = yield call(openbis.getSpaces)
     yield put(actions.setSpaces(result.getObjects()))
-  } catch(exception) {
-    yield put(actions.error(exception))  
-  }
+  })
 }
 
 function* logout() {
-  try {
-    const result = yield call(openbis.logout)
+  yield handleException(function*() {
+    yield call(openbis.logout)
     yield put(actions.logoutDone())
-  } catch(exception) {
-    yield put(actions.error(exception))  
-  }
+  })
 }
 
 function* selectSpace(action) {
@@ -31,19 +61,17 @@ function* selectSpace(action) {
 }
 
 function* saveEntity(action) {
-  try {
+  yield handleException(function*() {
     yield openbis.updateSpace(action.entity.permId, action.entity.description)
     const result = yield call(openbis.getSpaces)
     const spaces = result.getObjects()
     const space = spaces.filter(space => space.permId.permId === action.entity.permId.permId)[0]
-    yield put(actions.saveEntityDone(space))
-  } catch(exception) {
-    yield put(actions.error(exception))  
-  }
+    yield put(actions.saveEntityDone(space))    
+  })
 }
 
 function* expandNode(action) {
-  try {
+  yield handleException(function*() {
     const node = action.node
     if (node.loaded === false) {
       if (node.type === 'as.dto.space.Space') {
@@ -52,26 +80,5 @@ function* expandNode(action) {
         yield put(actions.setProjects(projects, node.id))
       }
     }
-  } catch(exception) {
-    yield put(actions.error(exception))  
-  }
-}
-
-export function* login(action) {
-  try {
-    const result = yield openbis.login(action.username, action.password)
-    yield put(actions.loginDone())
-  } catch(exception) {
-    yield put(actions.error(exception))
-  }
-}
-
-export function* watchActions() {
-  yield takeEvery('INIT', init)
-  yield takeEvery('LOGIN', login)
-  yield takeEvery('LOGIN-DONE', loginDone)
-  yield takeEvery('LOGOUT', logout)
-  yield takeEvery('SAVE-ENTITY', saveEntity)
-  yield takeEvery('SET-SPACES', selectSpace)
-  yield takeEvery('EXPAND-NODE', expandNode)
+  })
 }
diff --git a/openbis_ng_ui/src/reducer/sagas.test.js b/openbis_ng_ui/src/reducer/sagas.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..0ffd9c891b118950073aa53384df4e44a78595b9
--- /dev/null
+++ b/openbis_ng_ui/src/reducer/sagas.test.js
@@ -0,0 +1,81 @@
+import Openbis from '../services/openbis'
+import actions from './actions'
+import { newOpenbis } from './sagas.js'
+import { store } from './middleware'
+
+// These tests test sagas, reducer and the interaction with openbis.
+
+// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ setup ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+// replace the exported Openbis class by a mock generator
+jest.mock('../services/openbis')
+
+// set in beforeEach
+let openbis = null
+
+// before each test, remove the openbis mock and create a new one
+// to reset all captured calls
+beforeEach(() => {
+  Openbis.mockClear()
+  openbis = newOpenbis()
+  openbis.getSpaces.mockReturnValue({ getObjects: getSpaces })
+})
+
+// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ tests ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+describe('sagas', () => {
+  it('should login and load spaces', () => {
+    // given
+    const username = 'mark.watney'
+    const password = 'secret'
+    // when
+    store.dispatch(actions.login(username, password))
+    // then
+    const state = store.getState()
+    const spaces = getSpaces()
+    expect(openbis.login).toHaveBeenCalledWith(username, password)
+    expect(state.sessionActive).toEqual(true)
+    expect(openbis.getSpaces).toHaveBeenCalled()
+    expect(state.exceptions).toEqual([])
+    expect(state.database.spaces).toEqual({
+      '0': spaces[0],
+      '1': spaces[1],
+      '2': spaces[2],
+    })
+  })
+})
+
+describe('sagas', () => {
+  it('should logout', () => {
+    // given
+    store.dispatch(actions.login('kiva.lagos', 'secret'))
+    // when
+    store.dispatch(actions.logout())
+    // then
+    const state = store.getState()
+    expect(openbis.logout).toHaveBeenCalled()
+    expect(state.sessionActive).toEqual(false)
+  })
+})
+
+describe('sagas', () => {
+  it('should save entity', () => {
+    // given
+    store.dispatch(actions.login('paul.atreides', 'secret'))
+    // when
+    store.dispatch(actions.saveEntity(getSpaces()[0]))
+    // then
+    const space = getSpaces()[0]
+    expect(openbis.updateSpace).toHaveBeenCalledWith(space.permId, space.description)
+  })
+})
+
+// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ mocked data ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+function getSpaces() {
+  return [
+    { permId: { permId: '0' }, description: 'desc0' },
+    { permId: { permId: '1' }, description: 'desc1' },
+    { permId: { permId: '2' }, description: 'desc2' },
+  ]
+}
diff --git a/openbis_ng_ui/src/services/openbis.js b/openbis_ng_ui/src/services/openbis.js
index 4362afe4850c8cc706273905720b911a3c18eaa2..51ec7bac4887c72365c01a2f45257b895067a362 100644
--- a/openbis_ng_ui/src/services/openbis.js
+++ b/openbis_ng_ui/src/services/openbis.js
@@ -1,72 +1,75 @@
-import { store } from '../index.js'
+const autoBind = require('auto-bind')
 
 
-let v3 = null
-/* eslint-disable-next-line no-undef */
-requirejs([ 'openbis' ], openbis => {
-  v3 = new openbis()
-  store.dispatch({type: 'INIT'})
-})
+export default class Openbis {
 
-function login(user, password) {
-  return new Promise( (resolve, reject) => {
-    v3.login(user, password).done(resolve).fail(() => {
-      reject({ message: 'Login failed' })
+  constructor() {
+    autoBind(this)
+    let _this = this
+    /* eslint-disable-next-line no-undef */
+    requirejs([ 'openbis' ], openbis => {
+      _this.v3 = new openbis()
     })
-  })
-}
+  }
 
-function logout() {
-  return new Promise( (resolve, reject) => {
-    v3.logout().done(resolve).fail(reject)
-  })
-}
+  login(user, password) {
+    let v3 = this.v3
+    return new Promise( (resolve, reject) => {
+      v3.login(user, password).done(resolve).fail(() => {
+        reject({ message: 'Login failed' })
+      })
+    })
+  }
 
-function getSpaces() {
-  return new Promise( (resolve, reject) => {
-    /* eslint-disable-next-line no-undef */
-    requirejs(
-      ['as/dto/space/search/SpaceSearchCriteria', 'as/dto/space/fetchoptions/SpaceFetchOptions' ], 
-      (SpaceSearchCriteria, SpaceFetchOptions) => 
-        v3.searchSpaces(new SpaceSearchCriteria(), new SpaceFetchOptions()).done(resolve).fail(reject)
-    )
-  })
-}
+  logout() {
+    let v3 = this.v3
+    return new Promise( (resolve, reject) => {
+      v3.logout().done(resolve).fail(reject)
+    })
+  }
 
-function updateSpace(permId, description) {
-  return new Promise( (resolve, reject) => {
-    /* eslint-disable-next-line no-undef */
-    requirejs(
-      ['as/dto/space/update/SpaceUpdate'], 
-      (SpaceUpdate) => {
-        let spaceUpdate = new SpaceUpdate()
-        spaceUpdate.setSpaceId(permId)
-        spaceUpdate.setDescription(description)
-        v3.updateSpaces([spaceUpdate]).done(resolve).fail(reject)
-      }
-    )
-  })
-}
+  getSpaces() {
+    let v3 = this.v3
+    return new Promise( (resolve, reject) => {
+      /* eslint-disable-next-line no-undef */
+      requirejs(
+        ['as/dto/space/search/SpaceSearchCriteria', 'as/dto/space/fetchoptions/SpaceFetchOptions' ], 
+        (SpaceSearchCriteria, SpaceFetchOptions) => 
+          v3.searchSpaces(new SpaceSearchCriteria(), new SpaceFetchOptions()).done(resolve).fail(reject)
+      )
+    })
+  }
 
-function searchProjects(spacePermId) {
-  return new Promise( (resolve, reject) => {
-    /* eslint-disable-next-line no-undef */
-    requirejs(
-      ['as/dto/project/search/ProjectSearchCriteria', 'as/dto/project/fetchoptions/ProjectFetchOptions'], 
-      (ProjectSearchCriteria, ProjectFetchOptions) => {
-        let searchCriteria = new ProjectSearchCriteria()
-        searchCriteria.withSpace().withPermId().thatEquals(spacePermId)
-        let fetchOptions = new ProjectFetchOptions()
-        v3.searchProjects(searchCriteria, fetchOptions).done(resolve).fail(reject)
-      }
-    )
-  })
-}
+  updateSpace(permId, description) {
+    let v3 = this.v3
+    return new Promise( (resolve, reject) => {
+      /* eslint-disable-next-line no-undef */
+      requirejs(
+        ['as/dto/space/update/SpaceUpdate'], 
+        (SpaceUpdate) => {
+          let spaceUpdate = new SpaceUpdate()
+          spaceUpdate.setSpaceId(permId)
+          spaceUpdate.setDescription(description)
+          v3.updateSpaces([spaceUpdate]).done(resolve).fail(reject)
+        }
+      )
+    })
+  }
+
+  searchProjects(spacePermId) {
+    let v3 = this.v3
+    return new Promise( (resolve, reject) => {
+      /* eslint-disable-next-line no-undef */
+      requirejs(
+        ['as/dto/project/search/ProjectSearchCriteria', 'as/dto/project/fetchoptions/ProjectFetchOptions'], 
+        (ProjectSearchCriteria, ProjectFetchOptions) => {
+          let searchCriteria = new ProjectSearchCriteria()
+          searchCriteria.withSpace().withPermId().thatEquals(spacePermId)
+          let fetchOptions = new ProjectFetchOptions()
+          v3.searchProjects(searchCriteria, fetchOptions).done(resolve).fail(reject)
+        }
+      )
+    })
+  }
 
-export default {
-  login: login,
-  logout: logout,
-  getSpaces: getSpaces,
-  updateSpace: updateSpace,
-  searchProjects: searchProjects,
 }