diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go
index df3d4c9d39493355b8112bc07d67a66e9142c4a9..d54fa9ace64efbf6f6de125d7d2c06cb4235c934 100644
--- a/connector/ldap/ldap.go
+++ b/connector/ldap/ldap.go
@@ -154,6 +154,10 @@ func (c *Config) OpenConnector(logger logrus.FieldLogger) (interface {
 	connector.PasswordConnector
 	connector.RefreshConnector
 }, error) {
+	return c.openConnector(logger)
+}
+
+func (c *Config) openConnector(logger logrus.FieldLogger) (*ldapConnector, error) {
 
 	requiredFields := []struct {
 		name string
diff --git a/connector/ldap/ldap_test.go b/connector/ldap/ldap_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..43c86ddae5f41ebbd456f97cffed8f8ee7bafb8f
--- /dev/null
+++ b/connector/ldap/ldap_test.go
@@ -0,0 +1,453 @@
+package ldap
+
+import (
+	"bytes"
+	"context"
+	"io/ioutil"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sync"
+	"testing"
+	"text/template"
+	"time"
+
+	"github.com/Sirupsen/logrus"
+	"github.com/kylelemons/godebug/pretty"
+
+	"github.com/coreos/dex/connector"
+)
+
+const envVar = "DEX_LDAP_TESTS"
+
+// subtest is a login test against a given schema.
+type subtest struct {
+	// Name of the sub-test.
+	name string
+
+	// Password credentials, and if the connector should request
+	// groups as well.
+	username string
+	password string
+	groups   bool
+
+	// Expected result of the login.
+	wantErr   bool
+	wantBadPW bool
+	want      connector.Identity
+}
+
+func TestQuery(t *testing.T) {
+	schema := `
+dn: dc=example,dc=org
+objectClass: dcObject
+objectClass: organization
+o: Example Company
+dc: example
+
+dn: ou=People,dc=example,dc=org
+objectClass: organizationalUnit
+ou: People
+
+dn: cn=jane,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: iNetOrgPerson
+sn: doe
+cn: jane
+mail: janedoe@example.com
+userpassword: foo
+
+dn: cn=john,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: iNetOrgPerson
+sn: doe
+cn: john
+mail: johndoe@example.com
+userpassword: bar
+`
+	c := &Config{}
+	c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
+	c.UserSearch.NameAttr = "cn"
+	c.UserSearch.EmailAttr = "mail"
+	c.UserSearch.IDAttr = "DN"
+	c.UserSearch.Username = "cn"
+
+	tests := []subtest{
+		{
+			name:     "validpassword",
+			username: "jane",
+			password: "foo",
+			want: connector.Identity{
+				UserID:        "cn=jane,ou=People,dc=example,dc=org",
+				Username:      "jane",
+				Email:         "janedoe@example.com",
+				EmailVerified: true,
+			},
+		},
+		{
+			name:     "validpassword2",
+			username: "john",
+			password: "bar",
+			want: connector.Identity{
+				UserID:        "cn=john,ou=People,dc=example,dc=org",
+				Username:      "john",
+				Email:         "johndoe@example.com",
+				EmailVerified: true,
+			},
+		},
+		{
+			name:      "invalidpassword",
+			username:  "jane",
+			password:  "badpassword",
+			wantBadPW: true,
+		},
+		{
+			name:      "invaliduser",
+			username:  "idontexist",
+			password:  "foo",
+			wantBadPW: true, // Want invalid password, not a query error.
+		},
+	}
+
+	runTests(t, schema, c, tests)
+}
+
+func TestGroupQuery(t *testing.T) {
+	schema := `
+dn: dc=example,dc=org
+objectClass: dcObject
+objectClass: organization
+o: Example Company
+dc: example
+
+dn: ou=People,dc=example,dc=org
+objectClass: organizationalUnit
+ou: People
+
+dn: cn=jane,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: iNetOrgPerson
+sn: doe
+cn: jane
+mail: janedoe@example.com
+userpassword: foo
+
+dn: cn=john,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: iNetOrgPerson
+sn: doe
+cn: john
+mail: johndoe@example.com
+userpassword: bar
+
+# Group definitions.
+
+dn: ou=Groups,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Groups
+
+dn: cn=admins,ou=Groups,dc=example,dc=org
+objectClass: groupOfNames
+cn: admins
+member: cn=john,ou=People,dc=example,dc=org
+member: cn=jane,ou=People,dc=example,dc=org
+
+dn: cn=developers,ou=Groups,dc=example,dc=org
+objectClass: groupOfNames
+cn: developers
+member: cn=jane,ou=People,dc=example,dc=org
+`
+	c := &Config{}
+	c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
+	c.UserSearch.NameAttr = "cn"
+	c.UserSearch.EmailAttr = "mail"
+	c.UserSearch.IDAttr = "DN"
+	c.UserSearch.Username = "cn"
+	c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
+	c.GroupSearch.UserAttr = "DN"
+	c.GroupSearch.GroupAttr = "member"
+	c.GroupSearch.NameAttr = "cn"
+
+	tests := []subtest{
+		{
+			name:     "validpassword",
+			username: "jane",
+			password: "foo",
+			groups:   true,
+			want: connector.Identity{
+				UserID:        "cn=jane,ou=People,dc=example,dc=org",
+				Username:      "jane",
+				Email:         "janedoe@example.com",
+				EmailVerified: true,
+				Groups:        []string{"admins", "developers"},
+			},
+		},
+		{
+			name:     "validpassword2",
+			username: "john",
+			password: "bar",
+			groups:   true,
+			want: connector.Identity{
+				UserID:        "cn=john,ou=People,dc=example,dc=org",
+				Username:      "john",
+				Email:         "johndoe@example.com",
+				EmailVerified: true,
+				Groups:        []string{"admins"},
+			},
+		},
+	}
+
+	runTests(t, schema, c, tests)
+}
+
+// runTests runs a set of tests against an LDAP schema. It does this by
+// setting up an OpenLDAP server and injecting the provided scheme.
+//
+// The tests require the slapd and ldapadd binaries available in the host
+// machine's PATH.
+//
+// The DEX_LDAP_TESTS must be set to "1"
+func runTests(t *testing.T, schema string, config *Config, tests []subtest) {
+	if os.Getenv(envVar) != "1" {
+		t.Skipf("%s not set. Skipping test (run 'export %s=1' to run tests)", envVar, envVar)
+	}
+
+	for _, cmd := range []string{"slapd", "ldapadd"} {
+		if _, err := exec.LookPath(cmd); err != nil {
+			t.Errorf("%s not available", cmd)
+		}
+	}
+
+	tempDir, err := ioutil.TempDir("", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.RemoveAll(tempDir)
+
+	configBytes := new(bytes.Buffer)
+
+	if err := slapdConfigTmpl.Execute(configBytes, tmplData{tempDir, includes(t)}); err != nil {
+		t.Fatal(err)
+	}
+
+	configPath := filepath.Join(tempDir, "ldap.conf")
+	if err := ioutil.WriteFile(configPath, configBytes.Bytes(), 0644); err != nil {
+		t.Fatal(err)
+	}
+	schemaPath := filepath.Join(tempDir, "schema.ldap")
+	if err := ioutil.WriteFile(schemaPath, []byte(schema), 0644); err != nil {
+		t.Fatal(err)
+	}
+
+	socketPath := url.QueryEscape(filepath.Join(tempDir, "ldap.unix"))
+
+	slapdOut := new(bytes.Buffer)
+
+	cmd := exec.Command(
+		"slapd",
+		"-d", "any",
+		"-h", "ldap://localhost:10363/ ldaps://localhost:10636/ ldapi://"+socketPath,
+		"-f", configPath,
+	)
+	cmd.Stdout = slapdOut
+	cmd.Stderr = slapdOut
+	if err := cmd.Start(); err != nil {
+		t.Fatal(err)
+	}
+
+	var (
+		// Wait group finishes once slapd has exited.
+		//
+		// Use a wait group because multiple goroutines can't listen on
+		// cmd.Wait(). It triggers the race detector.
+		wg = new(sync.WaitGroup)
+		// Ensure only one condition can set the slapdFailed boolean.
+		once        = new(sync.Once)
+		slapdFailed bool
+	)
+
+	wg.Add(1)
+	go func() { cmd.Wait(); wg.Done() }()
+
+	defer func() {
+		if slapdFailed {
+			// If slapd exited before it was killed, print its logs.
+			t.Logf("%s\n", slapdOut)
+		}
+	}()
+
+	go func() {
+		wg.Wait()
+		once.Do(func() { slapdFailed = true })
+	}()
+
+	defer func() {
+		once.Do(func() { slapdFailed = false })
+		cmd.Process.Kill()
+		wg.Wait()
+	}()
+
+	// Wait for slapd to come up.
+	time.Sleep(100 * time.Millisecond)
+
+	ldapadd := exec.Command(
+		"ldapadd", "-x",
+		"-D", "cn=admin,dc=example,dc=org",
+		"-w", "admin",
+		"-f", schemaPath,
+		"-H", "ldap://localhost:10363/",
+	)
+	if out, err := ldapadd.CombinedOutput(); err != nil {
+		t.Errorf("ldapadd: %s", out)
+		return
+	}
+
+	// Shallow copy.
+	c := *config
+
+	// We need to configure host parameters but don't want to overwrite user or
+	// group search configuration.
+	c.Host = "localhost:10363"
+	c.InsecureNoSSL = true
+	c.BindDN = "cn=admin,dc=example,dc=org"
+	c.BindPW = "admin"
+
+	l := &logrus.Logger{Out: ioutil.Discard, Formatter: &logrus.TextFormatter{}}
+
+	conn, err := c.openConnector(l)
+	if err != nil {
+		t.Errorf("open connector: %v", err)
+	}
+
+	for _, test := range tests {
+		if test.name == "" {
+			t.Fatal("go a subtest with no name")
+		}
+
+		// Run the subtest.
+		t.Run(test.name, func(t *testing.T) {
+			s := connector.Scopes{OfflineAccess: true, Groups: test.groups}
+			ident, validPW, err := conn.Login(context.Background(), s, test.username, test.password)
+			if err != nil {
+				if !test.wantErr {
+					t.Fatalf("query failed: %v", err)
+				}
+				return
+			}
+			if test.wantErr {
+				t.Fatalf("wanted query to fail")
+			}
+
+			if !validPW {
+				if !test.wantBadPW {
+					t.Fatalf("invalid password: %v", err)
+				}
+				return
+			}
+
+			if test.wantBadPW {
+				t.Fatalf("wanted invalid password")
+			}
+			got := ident
+			got.ConnectorData = nil
+
+			if diff := pretty.Compare(test.want, got); diff != "" {
+				t.Error(diff)
+				return
+			}
+
+			// Verify that refresh tokens work.
+			ident, err = conn.Refresh(context.Background(), s, ident)
+			if err != nil {
+				t.Errorf("refresh failed: %v", err)
+			}
+
+			got = ident
+			got.ConnectorData = nil
+
+			if diff := pretty.Compare(test.want, got); diff != "" {
+				t.Errorf("after refresh: %s", diff)
+			}
+		})
+	}
+}
+
+// Standard OpenLDAP schema files to include.
+//
+// These are copied from the /etc/openldap/schema directory.
+var includeFiles = []string{
+	"core.schema",
+	"cosine.schema",
+	"inetorgperson.schema",
+	"misc.schema",
+	"nis.schema",
+	"openldap.schema",
+}
+
+// tmplData is the struct used to execute the SLAPD config template.
+type tmplData struct {
+	// Directory for database to be writen to.
+	TempDir string
+	// List of schema files to include.
+	Includes []string
+}
+
+// Config template copied from:
+// http://www.zytrax.com/books/ldap/ch5/index.html#step1-slapd
+var slapdConfigTmpl = template.Must(template.New("").Parse(`
+{{ range $i, $include := .Includes }}
+include {{ $include }}
+{{ end }}
+
+# MODULELOAD definitions
+# not required (comment out) before version 2.3
+moduleload back_bdb.la
+
+database bdb
+suffix "dc=example,dc=org"
+
+# root or superuser
+rootdn "cn=admin,dc=example,dc=org"
+rootpw admin
+# The database directory MUST exist prior to running slapd AND 
+# change path as necessary
+directory	{{ .TempDir }}
+
+# Indices to maintain for this directory
+# unique id so equality match only
+index	uid	eq
+# allows general searching on commonname, givenname and email
+index	cn,gn,mail eq,sub
+# allows multiple variants on surname searching
+index sn eq,sub
+# sub above includes subintial,subany,subfinal
+# optimise department searches
+index ou eq
+# if searches will include objectClass uncomment following
+# index objectClass eq
+# shows use of default index parameter
+index default eq,sub
+# indices missing - uses default eq,sub
+index telephonenumber
+
+# other database parameters
+# read more in slapd.conf reference section
+cachesize 10000
+checkpoint 128 15
+`))
+
+func includes(t *testing.T) (paths []string) {
+	wd, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("getting working directory: %v", err)
+	}
+	for _, f := range includeFiles {
+		p := filepath.Join(wd, "testdata", f)
+		if _, err := os.Stat(p); err != nil {
+			t.Fatalf("failed to find schema file: %s %v", p, err)
+		}
+		paths = append(paths, p)
+	}
+	return
+}