Frage

Wir haben festgestellt, dass sich die Unit-Tests, die wir für unseren C#/C++-Code geschrieben haben, wirklich ausgezahlt haben.Aber wir haben immer noch Tausende von Geschäftslogikzeilen in gespeicherten Prozeduren, die erst dann richtig auf die Probe gestellt werden, wenn unser Produkt einer großen Anzahl von Benutzern zur Verfügung gestellt wird.

Erschwerend kommt hinzu, dass einige dieser gespeicherten Prozeduren aufgrund der Leistungseinbußen bei der Übergabe temporärer Tabellen zwischen SPs am Ende sehr lang sind.Dies hat uns daran gehindert, den Code umzugestalten, um ihn einfacher zu machen.

Wir haben mehrere Versuche unternommen, Komponententests rund um einige unserer wichtigsten gespeicherten Prozeduren zu erstellen (hauptsächlich zum Testen der Leistung), haben jedoch festgestellt, dass das Einrichten der Testdaten für diese Tests sehr schwierig ist.Am Ende kopieren wir beispielsweise Testdatenbanken.Darüber hinaus reagieren die Tests sehr empfindlich auf Änderungen und selbst auf die kleinste Änderung an einem gespeicherten Prozess.oder Tabelle erfordert eine große Menge an Änderungen an den Tests.Nachdem viele Builds aufgrund zeitweiser Fehlschläge dieser Datenbanktests abgebrochen sind, mussten wir sie einfach aus dem Build-Prozess herausnehmen.

Der Hauptteil meiner Fragen lautet also:Hat jemand jemals erfolgreich Unit-Tests für seine gespeicherten Prozeduren geschrieben?

Der zweite Teil meiner Fragen ist, ob Unit-Tests mit Linq einfacher wären/sind?

Ich dachte, dass Sie, anstatt Tabellen mit Testdaten einrichten zu müssen, einfach eine Sammlung von Testobjekten erstellen und Ihren Linq-Code in einer „Linq to Objects“-Situation testen könnten?(Ich bin ein völliger Linq-Neuling und weiß daher nicht, ob das überhaupt funktionieren würde.)

War es hilfreich?

Lösung

Ich bin vor einiger Zeit auf dasselbe Problem gestoßen und habe festgestellt, dass ich, wenn ich eine einfache abstrakte Basisklasse für den Datenzugriff erstellen würde, die es mir ermöglicht, eine Verbindung und eine Transaktion einzufügen, meine Sprocs einem Unit-Test unterziehen könnte, um zu sehen, ob sie in SQL die von mir erwartete Arbeit leisten Ich habe sie gebeten, dies zu tun und dann ein Rollback durchzuführen, damit keine Testdaten in der Datenbank verbleiben.

Das fühlte sich besser an als das übliche „Führen Sie ein Skript aus, um meine Testdatenbank einzurichten, und führen Sie dann nach der Testausführung eine Bereinigung der Junk-/Testdaten durch.“Dies fühlte sich auch näher an Unit-Tests an, da diese Tests alleine ausgeführt werden konnten, ohne dass „alles in der Datenbank ‚einfach so‘ sein muss, bevor ich diese Tests ausführe“.

Hier ist ein Ausschnitt der abstrakten Basisklasse, die für den Datenzugriff verwendet wird

Public MustInherit Class Repository(Of T As Class)
    Implements IRepository(Of T)

    Private mConnectionString As String = ConfigurationManager.ConnectionStrings("Northwind.ConnectionString").ConnectionString
    Private mConnection As IDbConnection
    Private mTransaction As IDbTransaction

    Public Sub New()
        mConnection = Nothing
        mTransaction = Nothing
    End Sub

    Public Sub New(ByVal connection As IDbConnection, ByVal transaction As IDbTransaction)
        mConnection = connection
        mTransaction = transaction
    End Sub

    Public MustOverride Function BuildEntity(ByVal cmd As SqlCommand) As List(Of T)

    Public Function ExecuteReader(ByVal Parameter As Parameter) As List(Of T) Implements IRepository(Of T).ExecuteReader
        Dim entityList As List(Of T)
        If Not mConnection Is Nothing Then
            Using cmd As SqlCommand = mConnection.CreateCommand()
                cmd.Transaction = mTransaction
                cmd.CommandType = Parameter.Type
                cmd.CommandText = Parameter.Text
                If Not Parameter.Items Is Nothing Then
                    For Each param As SqlParameter In Parameter.Items
                        cmd.Parameters.Add(param)
                    Next
                End If
                entityList = BuildEntity(cmd)
                If Not entityList Is Nothing Then
                    Return entityList
                End If
            End Using
        Else
            Using conn As SqlConnection = New SqlConnection(mConnectionString)
                Using cmd As SqlCommand = conn.CreateCommand()
                    cmd.CommandType = Parameter.Type
                    cmd.CommandText = Parameter.Text
                    If Not Parameter.Items Is Nothing Then
                        For Each param As SqlParameter In Parameter.Items
                            cmd.Parameters.Add(param)
                        Next
                    End If
                    conn.Open()
                    entityList = BuildEntity(cmd)
                    If Not entityList Is Nothing Then
                        Return entityList
                    End If
                End Using
            End Using
        End If

        Return Nothing
    End Function
End Class

Als nächstes sehen Sie eine Beispiel-Datenzugriffsklasse, die die obige Basis verwendet, um eine Liste von Produkten zu erhalten

Public Class ProductRepository
    Inherits Repository(Of Product)
    Implements IProductRepository

    Private mCache As IHttpCache

    'This const is what you will use in your app
    Public Sub New(ByVal cache As IHttpCache)
        MyBase.New()
        mCache = cache
    End Sub

    'This const is only used for testing so we can inject a connectin/transaction and have them roll'd back after the test
    Public Sub New(ByVal cache As IHttpCache, ByVal connection As IDbConnection, ByVal transaction As IDbTransaction)
        MyBase.New(connection, transaction)
        mCache = cache
    End Sub

    Public Function GetProducts() As System.Collections.Generic.List(Of Product) Implements IProductRepository.GetProducts
        Dim Parameter As New Parameter()
        Parameter.Type = CommandType.StoredProcedure
        Parameter.Text = "spGetProducts"
        Dim productList As List(Of Product)
        productList = MyBase.ExecuteReader(Parameter)
        Return productList
    End Function

    'This function is used in each class that inherits from the base data access class so we can keep all the boring left-right mapping code in 1 place per object
    Public Overrides Function BuildEntity(ByVal cmd As System.Data.SqlClient.SqlCommand) As System.Collections.Generic.List(Of Product)
        Dim productList As New List(Of Product)
        Using reader As SqlDataReader = cmd.ExecuteReader()
            Dim product As Product
            While reader.Read()
                product = New Product()
                product.ID = reader("ProductID")
                product.SupplierID = reader("SupplierID")
                product.CategoryID = reader("CategoryID")
                product.ProductName = reader("ProductName")
                product.QuantityPerUnit = reader("QuantityPerUnit")
                product.UnitPrice = reader("UnitPrice")
                product.UnitsInStock = reader("UnitsInStock")
                product.UnitsOnOrder = reader("UnitsOnOrder")
                product.ReorderLevel = reader("ReorderLevel")
                productList.Add(product)
            End While
            If productList.Count > 0 Then
                Return productList
            End If
        End Using
        Return Nothing
    End Function
End Class

Und jetzt können Sie in Ihrem Unit-Test auch von einer sehr einfachen Basisklasse erben, die Ihre Setup-/Rollback-Arbeiten erledigt – oder dies auf einer Pro-Unit-Test-Basis beibehalten

Unten ist die einfache Testbasisklasse, die ich verwendet habe

Imports System.Configuration
Imports System.Data
Imports System.Data.SqlClient
Imports Microsoft.VisualStudio.TestTools.UnitTesting

Public MustInherit Class TransactionFixture
    Protected mConnection As IDbConnection
    Protected mTransaction As IDbTransaction
    Private mConnectionString As String = ConfigurationManager.ConnectionStrings("Northwind.ConnectionString").ConnectionString

    <TestInitialize()> _
    Public Sub CreateConnectionAndBeginTran()
        mConnection = New SqlConnection(mConnectionString)
        mConnection.Open()
        mTransaction = mConnection.BeginTransaction()
    End Sub

    <TestCleanup()> _
    Public Sub RollbackTranAndCloseConnection()
        mTransaction.Rollback()
        mTransaction.Dispose()
        mConnection.Close()
        mConnection.Dispose()
    End Sub
End Class

Und schließlich: Das Folgende ist ein einfacher Test mit dieser Testbasisklasse, der zeigt, wie der gesamte CRUD-Zyklus getestet wird, um sicherzustellen, dass alle Sprocs ihre Arbeit erledigen und dass Ihr ado.net-Code die Links-Rechts-Zuordnung korrekt durchführt

Ich weiß, dass hiermit nicht der Sproc „spGetProducts“ getestet wird, der im obigen Datenzugriffsbeispiel verwendet wird, aber Sie sollten die Leistungsfähigkeit dieses Ansatzes zum Unit-Testen von Sprocs erkennen

Imports SampleApplication.Library
Imports System.Collections.Generic
Imports Microsoft.VisualStudio.TestTools.UnitTesting

<TestClass()> _
Public Class ProductRepositoryUnitTest
    Inherits TransactionFixture

    Private mRepository As ProductRepository

    <TestMethod()> _
    Public Sub Should-Insert-Update-And-Delete-Product()
        mRepository = New ProductRepository(New HttpCache(), mConnection, mTransaction)
        '** Create a test product to manipulate throughout **'
        Dim Product As New Product()
        Product.ProductName = "TestProduct"
        Product.SupplierID = 1
        Product.CategoryID = 2
        Product.QuantityPerUnit = "10 boxes of stuff"
        Product.UnitPrice = 14.95
        Product.UnitsInStock = 22
        Product.UnitsOnOrder = 19
        Product.ReorderLevel = 12
        '** Insert the new product object into SQL using your insert sproc **'
        mRepository.InsertProduct(Product)
        '** Select the product object that was just inserted and verify it does exist **'
        '** Using your GetProductById sproc **'
        Dim Product2 As Product = mRepository.GetProduct(Product.ID)
        Assert.AreEqual("TestProduct", Product2.ProductName)
        Assert.AreEqual(1, Product2.SupplierID)
        Assert.AreEqual(2, Product2.CategoryID)
        Assert.AreEqual("10 boxes of stuff", Product2.QuantityPerUnit)
        Assert.AreEqual(14.95, Product2.UnitPrice)
        Assert.AreEqual(22, Product2.UnitsInStock)
        Assert.AreEqual(19, Product2.UnitsOnOrder)
        Assert.AreEqual(12, Product2.ReorderLevel)
        '** Update the product object **'
        Product2.ProductName = "UpdatedTestProduct"
        Product2.SupplierID = 2
        Product2.CategoryID = 1
        Product2.QuantityPerUnit = "a box of stuff"
        Product2.UnitPrice = 16.95
        Product2.UnitsInStock = 10
        Product2.UnitsOnOrder = 20
        Product2.ReorderLevel = 8
        mRepository.UpdateProduct(Product2) '**using your update sproc
        '** Select the product object that was just updated to verify it completed **'
        Dim Product3 As Product = mRepository.GetProduct(Product2.ID)
        Assert.AreEqual("UpdatedTestProduct", Product2.ProductName)
        Assert.AreEqual(2, Product2.SupplierID)
        Assert.AreEqual(1, Product2.CategoryID)
        Assert.AreEqual("a box of stuff", Product2.QuantityPerUnit)
        Assert.AreEqual(16.95, Product2.UnitPrice)
        Assert.AreEqual(10, Product2.UnitsInStock)
        Assert.AreEqual(20, Product2.UnitsOnOrder)
        Assert.AreEqual(8, Product2.ReorderLevel)
        '** Delete the product and verify it does not exist **'
        mRepository.DeleteProduct(Product3.ID)
        '** The above will use your delete product by id sproc **'
        Dim Product4 As Product = mRepository.GetProduct(Product3.ID)
        Assert.AreEqual(Nothing, Product4)
    End Sub

End Class

Ich weiß, dass dies ein langes Beispiel ist, aber es hat geholfen, eine wiederverwendbare Klasse für den Datenzugriff und eine weitere wiederverwendbare Klasse für meine Tests zu haben, sodass ich die Einrichtungs-/Abbauarbeiten nicht immer wieder durchführen musste ;)

Andere Tipps

Hast du es versucht DBUnit?Es dient zum Unit-Testen Ihrer Datenbank, und zwar nur Ihrer Datenbank, ohne dass Sie Ihren C#-Code durchgehen müssen.

Wenn Sie über die Art von Code nachdenken, die Unit-Tests tendenziell fördern:Wenn Sie kleine Routinen mit hoher Kohärenz und geringer Kopplung verwenden, sollten Sie in der Lage sein, zumindest einen Teil des Problems zu erkennen.

In meiner zynischen Welt sind gespeicherte Prozeduren Teil des langjährigen Versuchs der RDBMS-Welt, Sie davon zu überzeugen, Ihre Geschäftsverarbeitung in die Datenbank zu verlagern, was Sinn macht, wenn man bedenkt, dass Serverlizenzkosten in der Regel mit Dingen wie der Prozessoranzahl zusammenhängen.Je mehr Dinge Sie in Ihrer Datenbank ausführen, desto mehr machen sie aus Ihnen.

Aber ich habe den Eindruck, dass es Ihnen eigentlich mehr um die Leistung geht, was eigentlich nicht die Aufgabe von Unit-Tests ist.Unit-Tests sollen ziemlich atomar sein und eher das Verhalten als die Leistung überprüfen.Und in diesem Fall benötigen Sie mit ziemlicher Sicherheit Ladevorgänge der Produktionsklasse, um Abfragepläne zu überprüfen.

Ich denke, Sie brauchen eine andere Klasse von Testumgebungen.Am einfachsten würde ich eine Kopie der Produktion vorschlagen, vorausgesetzt, die Sicherheit stellt kein Problem dar.Dann beginnen Sie für jedes Kandidaten-Release mit der vorherigen Version, migrieren mithilfe Ihrer Release-Prozeduren (die als Nebeneffekt für gute Tests sorgen) und führen Ihre Timings durch.

So ähnlich.

Der Schlüssel zum Testen gespeicherter Prozeduren besteht darin, ein Skript zu schreiben, das eine leere Datenbank mit im Voraus geplanten Daten füllt, um beim Aufruf der gespeicherten Prozeduren ein konsistentes Verhalten zu erzielen.

Ich muss meine Stimme dafür abgeben, gespeicherte Prozeduren stark zu bevorzugen und Ihre Geschäftslogik dort zu platzieren, wo ich (und die meisten Datenbankadministratoren) denken, dass sie hingehört: in der Datenbank.

Ich weiß, dass wir als Softwareentwickler schön überarbeiteten Code wünschen, der in unserer Lieblingssprache geschrieben ist und alle unsere wichtige Logik enthält, aber die Realität der Leistung in hochvolumigen Systemen und die kritische Natur der Datenintegrität erfordern, dass wir einige Kompromisse eingehen .SQL-Code kann hässlich, repetitiv und schwer zu testen sein, aber ich kann mir nicht vorstellen, wie schwierig es ist, eine Datenbank zu optimieren, ohne die vollständige Kontrolle über das Design der Abfragen zu haben.

Ich bin oft gezwungen, Abfragen komplett neu zu entwerfen, Änderungen am Datenmodell einzubeziehen, damit die Dinge in akzeptabler Zeit ausgeführt werden können.Bei gespeicherten Prozeduren kann ich sicherstellen, dass die Änderungen für den Aufrufer transparent sind, da eine gespeicherte Prozedur eine so hervorragende Kapselung bietet.

Ich gehe davon aus, dass Sie Unit-Tests in MSSQL wünschen.Bei DBUnit gibt es einige Einschränkungen bei der Unterstützung von MSSQL.NVarChar wird beispielsweise nicht unterstützt. Hier sind einige echte Benutzer und ihre Probleme mit DBUnit.

Gute Frage.

Ich habe ähnliche Probleme und bin (zumindest für mich) den Weg des geringsten Widerstands gegangen.

Es gibt eine Reihe anderer Lösungen, die andere erwähnt haben.Viele davon sind besser / reiner / geeigneter für andere.

Ich habe bereits Testdriven.NET/MbUnit verwendet, um mein C# zu testen, also habe ich einfach Tests zu jedem Projekt hinzugefügt, um die von dieser App verwendeten gespeicherten Prozeduren aufzurufen.

Ich weiß, ich weiß.Das hört sich furchtbar an, aber ich muss damit durchstarten manche Testen und von dort aus fortfahren.Dieser Ansatz bedeutet, dass ich trotz geringer Abdeckung einige gespeicherte Prozesse teste und gleichzeitig den Code teste, der sie aufruft.Das hat eine gewisse Logik.

Ich bin in genau der gleichen Situation wie der Originalposter.Es kommt auf Leistung versus Testbarkeit an.Meine Vorliebe liegt in der Testbarkeit (damit es funktioniert, richtig funktioniert, schnell funktioniert), was darauf hindeutet, dass die Geschäftslogik nicht in die Datenbank einfließt.Datenbanken fehlen nicht nur die Test-Frameworks, Code-Faktorierungskonstrukte sowie Code-Analyse- und Navigationstools, die in Sprachen wie Java zu finden sind, sondern hochfaktorisierter Datenbankcode ist auch langsam (im Gegensatz zu hochfaktorisiertem Java-Code).

Ich erkenne jedoch die Leistungsfähigkeit der Datenbanksatzverarbeitung.Bei richtiger Verwendung kann SQL mit sehr wenig Code unglaublich leistungsstarke Dinge leisten.Ich bin also damit einverstanden, dass eine satzbasierte Logik in der Datenbank vorhanden ist, auch wenn ich weiterhin alles tun werde, um sie einem Unit-Test zu unterziehen.

In diesem Zusammenhang scheint es, dass sehr langer und prozeduraler Datenbankcode oft ein Symptom für etwas anderes ist, und ich denke, dass solcher Code in testbaren Code umgewandelt werden kann, ohne dass es zu Leistungseinbußen kommt.Die Theorie besagt, dass solcher Code oft Batch-Prozesse darstellt, die periodisch große Datenmengen verarbeiten.Wenn diese Batch-Prozesse in kleinere Teile der Echtzeit-Geschäftslogik umgewandelt würden, die immer dann ausgeführt wird, wenn die Eingabedaten geändert werden, könnte diese Logik auf der mittleren Ebene ausgeführt werden (wo sie getestet werden kann), ohne dass die Leistung beeinträchtigt wird (da die Arbeit wird in kleinen Teilen in Echtzeit erledigt).Als Nebeneffekt entfallen dadurch auch die langen Rückkopplungsschleifen bei der Fehlerbehandlung im Batch-Prozess.Natürlich funktioniert dieser Ansatz nicht in allen Fällen, aber in einigen Fällen funktioniert er möglicherweise.Wenn in Ihrem System Unmengen solcher nicht testbarer Datenbankcodes für die Stapelverarbeitung vorhanden sind, kann der Weg zur Rettung außerdem lang und beschwerlich sein.YMMV.

Aber ich habe den Eindruck, dass es Ihnen eigentlich mehr um die Leistung geht, was eigentlich nicht die Aufgabe von Unit-Tests ist.Unit-Tests sollen ziemlich atomar sein und eher das Verhalten als die Leistung überprüfen.Und in diesem Fall benötigen Sie mit ziemlicher Sicherheit Ladevorgänge der Produktionsklasse, um Abfragepläne zu überprüfen.

Ich denke, dass es hier zwei recht unterschiedliche Testbereiche gibt:die Leistung und die tatsächliche Logik der gespeicherten Prozeduren.

Ich habe als Beispiel die Datenbankleistung in der Vergangenheit getestet und glücklicherweise haben wir einen Punkt erreicht, an dem die Leistung gut genug ist.

Ich stimme vollkommen zu, dass die Situation mit der gesamten Geschäftslogik in der Datenbank schlecht ist, aber es ist etwas, das wir geerbt haben, bevor die meisten unserer Entwickler dem Unternehmen beigetreten sind.

Allerdings übernehmen wir jetzt das Webservices-Modell für unsere neuen Funktionen und haben versucht, gespeicherte Prozeduren so weit wie möglich zu vermeiden, die Logik im C#-Code beizubehalten und SQLCommands auf die Datenbank auszulösen (obwohl dies jetzt bei Linq der Fall wäre). die bevorzugte Methode).Die vorhandenen SPs werden immer noch in gewissem Umfang genutzt, weshalb ich darüber nachgedacht habe, sie im Nachhinein einem Unit-Test zu unterziehen.

Sie können es auch versuchen Visual Studio für Datenbankprofis.Es geht hauptsächlich um Änderungsmanagement, verfügt aber auch über Tools zum Generieren von Testdaten und Unit-Tests.

Es ist allerdings ziemlich teuer.

Wir gebrauchen DataFresh Um Änderungen zwischen den einzelnen Tests rückgängig zu machen, ist das Testen von Sprocs relativ einfach.

Was noch fehlt, sind Code-Coverage-Tools.

Ich mache Unit-Tests für arme Leute.Wenn ich faul bin, besteht der Test nur aus ein paar gültigen Aufrufen mit möglicherweise problematischen Parameterwerten.

/*

--setup
Declare @foo int Set @foo = (Select top 1 foo from mytable)

--test
execute wish_I_had_more_Tests @foo

--look at rowcounts/look for errors
If @@rowcount=1 Print 'Ok!' Else Print 'Nokay!'

--Teardown
Delete from mytable where foo = @foo
*/
create procedure wish_I_had_more_Tests
as
select....

LINQ vereinfacht dies nur, wenn Sie die Logik aus Ihren gespeicherten Prozeduren entfernen und sie als Linq-Abfragen erneut implementieren.Das wäre auf jeden Fall viel robuster und einfacher zu testen.Es hört sich jedoch so an, als ob Ihre Anforderungen dies ausschließen würden.

TL;DR:Ihr Design weist Probleme auf.

Wir testen den C#-Code, der die SPs aufruft, einem Unit-Test.
Wir haben Build-Skripte, die saubere Testdatenbanken erstellen.
Und größere Exemplare befestigen und lösen wir während der Testvorrichtung.
Diese Tests könnten Stunden dauern, aber ich denke, es lohnt sich.

Eine Möglichkeit, den Code umzugestalten (ich gebe zu, ein hässlicher Hack) wäre, ihn über CPP (den C-Präprozessor), M4 (habe es nie ausprobiert) oder ähnliches zu generieren.Ich habe ein Projekt, das genau das tut, und es ist tatsächlich größtenteils praktikabel.

Der einzige Fall, für den ich denke, dass dies gültig sein könnte, ist 1) als Alternative zu gespeicherten KLOC+-Prozeduren und 2) und dies sind meine Fälle, in denen der Sinn des Projekts darin besteht, zu sehen, wie weit (in den Wahnsinn) man eine Technologie vorantreiben kann.

Oh Junge.Sprocs eignen sich nicht für (automatisierte) Unit-Tests.Ich mache sozusagen einen „Unit-Test“ meiner komplexen Sprocs, indem ich Tests in T-SQL-Batchdateien schreibe und die Ausgabe der Druckanweisungen und die Ergebnisse von Hand überprüfe.

Das Problem beim Unit-Testen jeder Art von datenbezogener Programmierung besteht darin, dass Sie zunächst über einen zuverlässigen Satz Testdaten verfügen müssen.Viel hängt auch von der Komplexität des gespeicherten Prozesses und seiner Funktion ab.Es wäre sehr schwierig, Unit-Tests für ein sehr komplexes Verfahren zu automatisieren, das viele Tabellen verändert.

Einige der anderen Poster haben einige einfache Möglichkeiten zur Automatisierung manueller Tests erwähnt und auch einige Tools, die Sie mit SQL Server verwenden können.Auf Oracle-Seite arbeitete PL/SQL-Guru Steven Feuerstein an einem kostenlosen Unit-Test-Tool für gespeicherte PL/SQL-Prozeduren namens utPLSQL.

Er ließ diese Bemühungen jedoch fallen und ging dann mit dem Code Tester für PL/SQL von Quest auf den Markt.Quest bietet eine kostenlose Testversion zum Herunterladen an.Ich bin kurz davor, es auszuprobieren;Meines Wissens nach ist es gut geeignet, den Aufwand beim Einrichten eines Test-Frameworks zu reduzieren, sodass Sie sich nur auf die Tests selbst konzentrieren können, und die Tests bleiben erhalten, sodass Sie sie in Regressionstests wiederverwenden können, einer der großen Vorteile von Testgetriebene Entwicklung.Darüber hinaus soll es mehr können als nur die Prüfung einer Ausgabevariablen und bietet auch die Möglichkeit, Datenänderungen zu validieren, aber ich muss selbst noch genauer hinschauen.Ich dachte, diese Informationen könnten für Oracle-Benutzer von Nutzen sein.

Lizenziert unter: CC-BY-SA mit Zuschreibung
Nicht verbunden mit StackOverflow
scroll top