diff --git a/Makefile b/Makefile
index 28d009ca5ffa87debf1cfa6f83403c32ca01254f..7d284b4656fed01da77dac9eb58a17577c7972b9 100644
--- a/Makefile
+++ b/Makefile
@@ -14,8 +14,8 @@ VERSION ?= $(shell git describe)
 
 # go paths
 GOPATH = $(shell go env GOPATH)
-SYSTRAY = 0xacab.org/leap/bitmask-vpn
-GOSYSTRAY = ${GOPATH}/src/${SYSTRAY}
+TARGET_GOLIB=lib/libgoshim.a
+SOURCE_GOLIB=gui/backend.go
 
 # detect OS, we use it for dependencies
 UNAME = $(shell uname -s)
@@ -81,6 +81,15 @@ build_%:
 test:
 	@go test -tags "integration $(TAGS)" ./...
 
+golib:
+	CGO_ENABLED=1 go build -buildmode=c-archive -o ${TARGET_GOLIB} ${SOURCE_GOLIB}
+
+test_ui: golib
+	@qmake -o tests/Makefile test.pro
+	@make -C tests clean
+	@make -C tests
+	@./tests/build/test_ui
+
 build_win:
 	powershell -Command '$$version=git describe --tags; go build -ldflags "-H windowsgui -X main.version=$$version" ./cmd/*'
 
diff --git a/README.md b/README.md
index 07593692ced900fba1a786f713d1673ce0a1d797..cb20665b701fe6882f03a54f6c66377c841e6d7d 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,14 @@ Linux
 ./build.sh
 
 
+Running tests
+-------------
+
+sudo apt install qml-module-qttest
+make test
+make test_ui
+
+
 i18n
 ----
 
diff --git a/bitmask.pro b/bitmask.pro
index 115aec710bee34a51d65f072f226e9f8f0c44314..7a294838d4a5a661a0f88eadad5a07bc65bbef02 100644
--- a/bitmask.pro
+++ b/bitmask.pro
@@ -33,7 +33,6 @@ MOC_DIR = release/.moc
 RCC_DIR = release/.rcc
 UI_DIR = release/.ui
 
-Release:DESTDIR = release
 Release:DESTDIR = release
 Release:OBJECTS_DIR = release/.obj
 Release:MOC_DIR = release/.moc
diff --git a/gui/backend.go b/gui/backend.go
index f7816ee8ce41137cc4ab7c923c91bfd94d0fa653..4a73cc261226a3232d6981f86a9f57e76831af98 100644
--- a/gui/backend.go
+++ b/gui/backend.go
@@ -44,7 +44,16 @@ func SubscribeToEvent(event string, f unsafe.Pointer) {
 
 //export InitializeBitmaskContext
 func InitializeBitmaskContext() {
-	backend.InitializeBitmaskContext()
+	opts := &backend.InitOpts{}
+	backend.InitializeBitmaskContext(opts)
+}
+
+//export InitializeTestBitmaskContext
+func InitializeTestBitmaskContext() {
+	opts := &backend.InitOpts{}
+	opts.SkipLaunch = true
+	backend.InitializeBitmaskContext(opts)
+	backend.EnableMockBackend()
 }
 
 //export RefreshContext
diff --git a/gui/qml/main.qml b/gui/qml/main.qml
index efe0111a53ca6842ba83a1c86c24a790da4ab67e..4aab7f19a3cade8f65e36589c8b88f079af27547 100644
--- a/gui/qml/main.qml
+++ b/gui/qml/main.qml
@@ -9,7 +9,7 @@ ApplicationWindow {
     id: app
     visible: false
 
-    property var     ctx
+    property var ctx
 
     Connections {
         target: jsonModel
diff --git a/pkg/backend/api.go b/pkg/backend/api.go
index a19fd4043bead94f9c12aceb5befae2c4b55c2a0..fea38dbcaaa14df1db4d5030c643c515f950dc72 100644
--- a/pkg/backend/api.go
+++ b/pkg/backend/api.go
@@ -45,10 +45,18 @@ func SubscribeToEvent(event string, f unsafe.Pointer) {
 	subscribe(event, f)
 }
 
-func InitializeBitmaskContext() {
+type InitOpts struct {
+	Provider   string
+	AppName    string
+	SkipLaunch bool
+}
+
+func InitializeBitmaskContext(opts *InitOpts) {
 	p := bitmask.GetConfiguredProvider()
+	opts.Provider = p.Provider
+	opts.AppName = p.AppName
 
-	initOnce.Do(func() { initializeContext(p.Provider, p.AppName) })
+	initOnce.Do(func() { initializeContext(opts) })
 	runDonationReminder()
 	go ctx.updateStatus()
 }
@@ -62,7 +70,7 @@ func InstallHelpers() {
 	pickle.InstallHelpers()
 }
 
-func MockUIInteraction() {
-	log.Println("mocking ui interaction on port 8080. \nTry 'curl localhost:8080/{on|off|failed}' to toggle status.")
-	go mockUI()
+func EnableMockBackend() {
+	log.Println("[+] Mocking ui interaction on port 8080. \nTry 'curl localhost:8080/{on|off|failed}' to toggle status.")
+	go enableMockBackend()
 }
diff --git a/pkg/backend/init.go b/pkg/backend/init.go
index 5abb05ecd122a622ca17ecd7c6cd147b3da12b0b..79efdc7878f90e0867ee5ffe83946bc837b74210 100644
--- a/pkg/backend/init.go
+++ b/pkg/backend/init.go
@@ -12,11 +12,11 @@ import (
 // initializeContext initializes an empty connStatus and assigns it to the
 // global ctx holder. This is expected to be called only once, so the public
 // api uses the sync.Once primitive to call this.
-func initializeContext(provider, appName string) {
+func initializeContext(opts *InitOpts) {
 	var st status = off
 	ctx = &connectionCtx{
-		AppName:         appName,
-		Provider:        provider,
+		AppName:         opts.AppName,
+		Provider:        opts.Provider,
 		TosURL:          config.TosURL,
 		HelpURL:         config.HelpURL,
 		DonateURL:       config.DonateURL,
@@ -28,7 +28,7 @@ func initializeContext(provider, appName string) {
 	errCh := make(chan string)
 	go trigger(OnStatusChanged)
 	go checkErrors(errCh)
-	initializeBitmask(errCh)
+	initializeBitmask(errCh, opts)
 }
 
 func checkErrors(errCh chan string) {
@@ -39,14 +39,14 @@ func checkErrors(errCh chan string) {
 	}
 }
 
-func initializeBitmask(errCh chan string) {
+func initializeBitmask(errCh chan string, opts *InitOpts) {
 	if ctx == nil {
 		log.Println("bug: cannot initialize bitmask, ctx is nil!")
 		os.Exit(1)
 	}
 	bitmask.InitializeLogger()
 
-	b, err := bitmask.InitializeBitmask()
+	b, err := bitmask.InitializeBitmask(opts.SkipLaunch)
 	if err != nil {
 		log.Println("error: cannot initialize bitmask")
 		errCh <- err.Error()
diff --git a/pkg/backend/mocks.go b/pkg/backend/mocks.go
index a8ede7363d07e9e198f60a150674ee1d9eb984a3..226fa4e6f7e9e4ef36716b1e51ee5be279f1c6fc 100644
--- a/pkg/backend/mocks.go
+++ b/pkg/backend/mocks.go
@@ -9,6 +9,14 @@ import (
 * should also show a good way of writing functionality tests just for the Qml
 * layer */
 
+func enableMockBackend() {
+	log.Println("[+] You should not use this in production!")
+	http.HandleFunc("/on", mockUIOn)
+	http.HandleFunc("/off", mockUIOff)
+	http.HandleFunc("/failed", mockUIFailed)
+	http.ListenAndServe(":8080", nil)
+}
+
 func mockUIOn(w http.ResponseWriter, r *http.Request) {
 	log.Println("changing status: on")
 	setStatus(on)
@@ -23,10 +31,3 @@ func mockUIFailed(w http.ResponseWriter, r *http.Request) {
 	log.Println("changing status: failed")
 	setStatus(failed)
 }
-
-func mockUI() {
-	http.HandleFunc("/on", mockUIOn)
-	http.HandleFunc("/off", mockUIOff)
-	http.HandleFunc("/failed", mockUIFailed)
-	http.ListenAndServe(":8080", nil)
-}
diff --git a/pkg/bitmask/init.go b/pkg/bitmask/init.go
index 33a5911fa2a0cd5520ed13460392cf57e8e4e841..a96ab870047c44bd2bb730089054ad1109d891d3 100644
--- a/pkg/bitmask/init.go
+++ b/pkg/bitmask/init.go
@@ -56,14 +56,18 @@ func initBitmask(printer *message.Printer) (Bitmask, error) {
 	return b, err
 }
 
-func InitializeBitmask() (Bitmask, error) {
+func InitializeBitmask(skipLaunch bool) (Bitmask, error) {
+	if skipLaunch {
+		log.Println("Initializing bitmask, but not launching it...")
+	}
 	if _, err := os.Stat(config.Path); os.IsNotExist(err) {
 		os.MkdirAll(config.Path, os.ModePerm)
 	}
 
 	err := pid.AcquirePID()
 	if err != nil {
-		log.Fatal(err)
+		log.Println("Error acquiring PID:", err)
+		return nil, err
 	}
 	defer pid.ReleasePID()
 
@@ -75,13 +79,21 @@ func InitializeBitmask() (Bitmask, error) {
 		return nil, err
 	}
 
-	err = checkAndStartBitmask(b, conf)
+	err = setTransport(b, conf)
 	if err != nil {
 		return nil, err
 	}
 
+	if !skipLaunch {
+		err := maybeStartVPN(b, conf)
+		if err != nil {
+			log.Println("Error starting VPN: ", err)
+			return nil, err
+		}
+	}
+
 	var as Autostart
-	if conf.DisableAustostart {
+	if skipLaunch || conf.DisableAustostart {
 		as = &dummyAutostart{}
 	} else {
 		as = newAutostart(config.ApplicationName, "")
@@ -103,7 +115,7 @@ func initPrinter() *message.Printer {
 	return message.NewPrinter(message.MatchLanguage(locale, "en"))
 }
 
-func checkAndStartBitmask(b Bitmask, conf *config.Config) error {
+func setTransport(b Bitmask, conf *config.Config) error {
 	if conf.Obfs4 {
 		err := b.UseTransport("obfs4")
 		if err != nil {
@@ -111,12 +123,6 @@ func checkAndStartBitmask(b Bitmask, conf *config.Config) error {
 			return err
 		}
 	}
-
-	err := maybeStartVPN(b, conf)
-	if err != nil {
-		log.Println("Error starting VPN: ", err)
-		return err
-	}
 	return nil
 }
 
diff --git a/test.pro b/test.pro
new file mode 100644
index 0000000000000000000000000000000000000000..099e18fabdddcf1eace328dccd75de3be9a96f67
--- /dev/null
+++ b/test.pro
@@ -0,0 +1,26 @@
+TEMPLATE = app
+TARGET = test_ui
+CONFIG += warn_on qmltestcase
+
+SOURCES += \
+    tests/test_ui.cpp \
+    gui/qjsonmodel.cpp \
+    gui/handlers.cpp
+
+HEADERS += \
+    lib/libgoshim.h \
+    gui/qjsonmodel.h \
+    gui/handlers.h
+
+
+LIBS += -L../lib -lgoshim -lpthread
+
+DESTDIR = build
+OBJECTS_DIR = build/.obj
+RCC_DIR = build/.rcc
+UI_DIR = build/.ui
+
+Release:DESTDIR = build
+Release:OBJECTS_DIR = build/.obj
+Release:RCC_DIR = build/.rcc
+Release:UI_DIR = build/.ui
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..65af933b2fd8663636581c84a5335e71080a93cb
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,10 @@
+build/*
+*.h
+*.sh
+*.moc
+*.o
+*.stash
+Makefile
+test_ui
+moc_handlers.cpp
+moc_qjsonmodel.cpp
diff --git a/tests/test_ui.cpp b/tests/test_ui.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f9f960a1717f8ef12d9214a94ab897346575066f
--- /dev/null
+++ b/tests/test_ui.cpp
@@ -0,0 +1,52 @@
+// test_ui.cpp
+#include <QtQuickTest>
+#include <QQmlEngine>
+#include <QQmlContext>
+
+#include "../gui/qjsonmodel.h"
+#include "../lib/libgoshim.h"
+
+class Helper :  public QObject
+{
+    Q_OBJECT
+
+public:
+    explicit Helper(QObject *parent = 0);
+
+public slots:
+    Q_INVOKABLE QString refreshContext();
+};
+
+Helper::Helper(QObject *parent) : QObject(parent)
+{
+}
+
+Q_INVOKABLE QString Helper::refreshContext()
+{
+    return QString(RefreshContext());
+}
+
+class Setup : public QObject
+{
+    Q_OBJECT
+
+public:
+    Setup() {}
+
+public slots:
+    void qmlEngineAvailable(QQmlEngine *engine)
+    {
+        QQmlContext *ctx = engine->rootContext();
+        QJsonModel *model = new QJsonModel;
+        Helper *helper = new Helper(this);
+
+        InitializeTestBitmaskContext();
+
+        ctx->setContextProperty("jsonModel", model);
+        ctx->setContextProperty("helper", helper);
+    }
+};
+
+QUICK_TEST_MAIN_WITH_SETUP(ui, Setup)
+
+#include "test_ui.moc"
diff --git a/tests/tst_smoke.qml b/tests/tst_smoke.qml
new file mode 100644
index 0000000000000000000000000000000000000000..19904a61420aeb4dfdbb129f75a4749b811035e2
--- /dev/null
+++ b/tests/tst_smoke.qml
@@ -0,0 +1,28 @@
+import QtQuick 2.3
+import QtTest 1.0
+
+
+TestCase {
+    name: "SmokeTests"
+
+    property var ctx
+
+    function refresh() {
+        ctx = JSON.parse(helper.refreshContext())
+    }
+
+    function test_helper() {
+        compare(Boolean(helper), true, "does helper exist?")
+    }
+
+    function test_model() {
+        compare(Boolean(jsonModel), true, "does model exist?")
+    }
+
+    function test_loadCtx() {
+        refresh()
+        compare(ctx.appName, "RiseupVPN", "can read appName?")
+        compare(ctx.tosURL, "https://riseup.net/tos", "can read tosURL?")
+        compare(ctx.status, "off", "is initial status off?")
+    }
+}